@animalabs/connectome-host 0.7.3 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +156 -10
  2. package/HEADLESS-FLEET-PLAN.md +22 -0
  3. package/README.md +12 -1
  4. package/docs/AGENT-ONBOARDING.md +1 -1
  5. package/docs/debug-context-api.md +2 -2
  6. package/docs/retrieval-traces.md +173 -0
  7. package/docs/webui-deployment.md +2 -1
  8. package/package.json +2 -2
  9. package/scripts/audit-module-optins.ts +288 -0
  10. package/src/framework-strategy.ts +13 -4
  11. package/src/headless.ts +14 -0
  12. package/src/index.ts +12 -9
  13. package/src/modules/fleet-module.ts +60 -1
  14. package/src/modules/fleet-types.ts +30 -1
  15. package/src/modules/mcpl-admin-module.ts +33 -4
  16. package/src/modules/retrieval-module.ts +249 -51
  17. package/src/modules/retrieval-trace-page.ts +254 -0
  18. package/src/modules/retrieval-trace.ts +904 -0
  19. package/src/modules/tts-relay-module.ts +33 -18
  20. package/src/modules/web-ui-module.ts +445 -894
  21. package/src/recipe.ts +55 -4
  22. package/src/retrieval-config.ts +39 -0
  23. package/src/strategies/frontdesk-strategy.ts +34 -125
  24. package/src/tui.ts +325 -54
  25. package/src/web/panel-data.ts +1187 -0
  26. package/src/web/protocol.ts +75 -10
  27. package/test/audit-module-optins.test.ts +167 -0
  28. package/test/fleet-panel-request.test.ts +90 -0
  29. package/test/framework-strategy-defaults.test.ts +22 -0
  30. package/test/frontdesk-strategy.test.ts +25 -37
  31. package/test/headless-panel-request.test.ts +201 -0
  32. package/test/mcpl-admin-module.test.ts +23 -0
  33. package/test/mock-headless-child.ts +14 -0
  34. package/test/retrieval-auth-loopback.test.ts +49 -0
  35. package/test/retrieval-config.test.ts +74 -0
  36. package/test/retrieval-module.test.ts +821 -0
  37. package/test/tui-format.test.ts +106 -0
  38. package/test/web-ui-context-coverage.test.ts +1 -1
  39. package/test/web-ui-module.test.ts +189 -3
  40. package/test/web-ui-observers.test.ts +8 -5
  41. package/test/web-ui-protocol.test.ts +0 -0
  42. package/web/src/App.tsx +159 -44
  43. package/web/src/Context.tsx +35 -8
  44. package/web/src/ContextDocument.tsx +20 -5
  45. package/web/src/Files.tsx +2 -8
  46. package/web/src/Lessons.tsx +2 -38
  47. package/web/src/Mcpl.tsx +80 -14
  48. package/web/src/Pins.tsx +5 -0
  49. package/web/src/Settings.tsx +5 -0
  50. package/web/vite.config.ts +8 -2
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Report-only audit of subagents/lessons/retrieval opt-ins across recipes.
3
+ *
4
+ * Background: published v0.7.2 and earlier treated these three modules as
5
+ * opt-OUT (an omitted `modules` key meant enabled), and DEFAULT_RECIPE
6
+ * enabled all three explicitly. Current main treats them as opt-IN
7
+ * (a4bd9fd). That flip is the right default, but it leaves existing
8
+ * deployments with two things only a human can settle:
9
+ *
10
+ * 1. A source recipe that explicitly says `lessons: true` stays enabled
11
+ * after upgrade — intentionally. Whether that `true` was a real choice
12
+ * or boilerplate copied from the old onboarding guide is not something
13
+ * a defaults change (or this script) can infer. It gets reported;
14
+ * the operator decides.
15
+ * 2. A persisted `data/.recipe.json` is a resolved snapshot of whatever
16
+ * was in effect at launch — under the old defaults that includes
17
+ * `subagents/lessons/retrieval: true` the operator never wrote. It is
18
+ * not necessarily the authoritative source recipe, so it's reported
19
+ * separately, as a pointer back to the source, never as a finding in
20
+ * itself.
21
+ *
22
+ * This script reads and reports. It never modifies a file, and it has no
23
+ * flag that would make it modify a file.
24
+ *
25
+ * Usage:
26
+ * bun scripts/audit-module-optins.ts <recipe.json | directory> [...more]
27
+ *
28
+ * Directories are scanned recursively for *.json (including .recipe.json
29
+ * snapshots; node_modules/.git skipped). Fleet children referenced by
30
+ * local path are followed automatically.
31
+ *
32
+ * Exit codes: 0 = nothing needs an operator decision; 2 = explicit enables
33
+ * (or inert-retrieval combinations) found — review the report; 1 = a path
34
+ * argument could not be read.
35
+ */
36
+
37
+ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
38
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
39
+
40
+ export const AUDITED_MODULES = ['subagents', 'lessons', 'retrieval'] as const;
41
+ export type AuditedModule = (typeof AUDITED_MODULES)[number];
42
+
43
+ export type ModuleState = 'explicit-enable' | 'explicit-disable' | 'omitted';
44
+
45
+ export interface RecipeAudit {
46
+ /** Path as reported (relative to cwd where possible). */
47
+ path: string;
48
+ recipeName: string;
49
+ /** basename === '.recipe.json': a resolved launch snapshot, not a source. */
50
+ isSnapshot: boolean;
51
+ states: Record<AuditedModule, ModuleState>;
52
+ /** Snapshot has all three explicitly true — the old DEFAULT_RECIPE shape.
53
+ * Almost certainly captured pre-flip defaults, not an operator choice. */
54
+ matchesOldDefaultBoilerplate: boolean;
55
+ /** retrieval explicitly enabled while lessons is omitted: worked under the
56
+ * old defaults (omitted lessons = on), silently inert after upgrade. */
57
+ inertRetrieval: boolean;
58
+ /** Local fleet-children recipe paths referenced by this recipe. */
59
+ childRecipePaths: string[];
60
+ }
61
+
62
+ interface RawRecipeShape {
63
+ name?: unknown;
64
+ agent?: unknown;
65
+ modules?: Record<string, unknown>;
66
+ }
67
+
68
+ /** A JSON document we treat as a recipe: object with a name and an agent
69
+ * block. Anything else in a scanned directory is silently skipped. */
70
+ export function looksLikeRecipe(raw: unknown): raw is RawRecipeShape {
71
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return false;
72
+ const o = raw as RawRecipeShape;
73
+ return typeof o.name === 'string' && !!o.agent && typeof o.agent === 'object';
74
+ }
75
+
76
+ export function classifyModule(value: unknown): ModuleState {
77
+ if (value === undefined || value === null) return 'omitted';
78
+ if (value === false) return 'explicit-disable';
79
+ // true or a config object both enable (matches createFramework wiring).
80
+ return 'explicit-enable';
81
+ }
82
+
83
+ export function auditRecipe(raw: RawRecipeShape, path: string): RecipeAudit {
84
+ const modules = (raw.modules && typeof raw.modules === 'object' ? raw.modules : {}) as Record<string, unknown>;
85
+ const states = Object.fromEntries(
86
+ AUDITED_MODULES.map((m) => [m, classifyModule(modules[m])]),
87
+ ) as Record<AuditedModule, ModuleState>;
88
+
89
+ const isSnapshot = basename(path) === '.recipe.json';
90
+
91
+ const childRecipePaths: string[] = [];
92
+ const fleet = modules.fleet;
93
+ if (fleet && typeof fleet === 'object') {
94
+ const children = (fleet as { children?: unknown }).children;
95
+ if (Array.isArray(children)) {
96
+ for (const child of children) {
97
+ const ref = (child as { recipe?: unknown })?.recipe;
98
+ if (typeof ref !== 'string' || !ref) continue;
99
+ if (ref.startsWith('http://') || ref.startsWith('https://')) continue;
100
+ childRecipePaths.push(isAbsolute(ref) ? ref : resolve(dirname(resolve(path)), ref));
101
+ }
102
+ }
103
+ }
104
+
105
+ return {
106
+ path,
107
+ recipeName: String(raw.name),
108
+ isSnapshot,
109
+ states,
110
+ matchesOldDefaultBoilerplate:
111
+ isSnapshot &&
112
+ modules.subagents === true && modules.lessons === true && modules.retrieval === true,
113
+ inertRetrieval: states.retrieval === 'explicit-enable' && states.lessons === 'omitted',
114
+ childRecipePaths,
115
+ };
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Filesystem walk (main-path only; the logic above is what the tests pin)
120
+ // ---------------------------------------------------------------------------
121
+
122
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'web']);
123
+
124
+ function collectJsonFiles(root: string, out: string[]): void {
125
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
126
+ const full = join(root, entry.name);
127
+ if (entry.isDirectory()) {
128
+ if (!SKIP_DIRS.has(entry.name)) collectJsonFiles(full, out);
129
+ } else if (entry.name.endsWith('.json')) {
130
+ out.push(full);
131
+ }
132
+ }
133
+ }
134
+
135
+ export function auditPaths(paths: string[]): { audits: RecipeAudit[]; unreadable: string[] } {
136
+ const files: string[] = [];
137
+ const unreadable: string[] = [];
138
+ for (const p of paths) {
139
+ if (!existsSync(p)) {
140
+ unreadable.push(p);
141
+ continue;
142
+ }
143
+ if (statSync(p).isDirectory()) collectJsonFiles(p, files);
144
+ else files.push(p);
145
+ }
146
+
147
+ const audits: RecipeAudit[] = [];
148
+ const seen = new Set<string>();
149
+ const queue = [...files];
150
+ while (queue.length > 0) {
151
+ const file = queue.shift()!;
152
+ const key = resolve(file);
153
+ if (seen.has(key)) continue;
154
+ seen.add(key);
155
+ let raw: unknown;
156
+ try {
157
+ raw = JSON.parse(readFileSync(file, 'utf-8'));
158
+ } catch {
159
+ continue; // not JSON we can read — not our business to complain about
160
+ }
161
+ if (!looksLikeRecipe(raw)) continue;
162
+ const audit = auditRecipe(raw, file);
163
+ audits.push(audit);
164
+ // Follow local fleet children so a parent path argument covers the tree.
165
+ for (const child of audit.childRecipePaths) {
166
+ if (existsSync(child)) queue.push(child);
167
+ }
168
+ }
169
+ return { audits, unreadable };
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Report
174
+ // ---------------------------------------------------------------------------
175
+
176
+ const STATE_LINES: Record<AuditedModule, Record<ModuleState, string>> = {
177
+ subagents: {
178
+ 'explicit-enable':
179
+ 'explicitly enabled — stays on after upgrade. Keep if this agent really forks workers.',
180
+ 'explicit-disable': 'explicitly disabled — no change on upgrade (belt-and-braces for old checkouts).',
181
+ omitted:
182
+ 'omitted — ON under published ≤0.7.2, OFF after upgrade. Declare `true` only if this agent relied on forking.',
183
+ },
184
+ lessons: {
185
+ 'explicit-enable':
186
+ 'explicitly enabled — stays on after upgrade. Keep if this agent curates a lesson library.',
187
+ 'explicit-disable': 'explicitly disabled — no change on upgrade (belt-and-braces for old checkouts).',
188
+ omitted:
189
+ 'omitted — ON under published ≤0.7.2, OFF after upgrade. Declare `true` only if this agent relied on lessons.',
190
+ },
191
+ retrieval: {
192
+ 'explicit-enable':
193
+ 'explicitly enabled — stays on after upgrade: per-compile injection plus two Haiku calls per turn. Keep only as a real choice.',
194
+ 'explicit-disable': 'explicitly disabled — no change on upgrade (belt-and-braces for old checkouts).',
195
+ omitted: 'omitted — ON under published ≤0.7.2 (when lessons ran), OFF after upgrade.',
196
+ },
197
+ };
198
+
199
+ export function renderReport(audits: RecipeAudit[]): { text: string; needsDecision: number } {
200
+ const lines: string[] = [];
201
+ let needsDecision = 0;
202
+
203
+ const sources = audits.filter((a) => !a.isSnapshot);
204
+ const snapshots = audits.filter((a) => a.isSnapshot);
205
+
206
+ for (const a of sources) {
207
+ lines.push(`${a.path} (recipe "${a.recipeName}")`);
208
+ for (const m of AUDITED_MODULES) {
209
+ const state = a.states[m];
210
+ if (state === 'explicit-enable') needsDecision++;
211
+ lines.push(` ${m}: ${STATE_LINES[m][state]}`);
212
+ }
213
+ if (a.inertRetrieval) {
214
+ needsDecision++;
215
+ lines.push(
216
+ ' ⚠ retrieval is enabled but lessons is omitted. Under the old defaults omitted lessons still ran,',
217
+ ' so retrieval worked; after upgrade lessons is off and retrieval is silently inert.',
218
+ ' Either add `lessons: true` (if retrieval is a real choice) or drop `retrieval`.',
219
+ );
220
+ }
221
+ lines.push('');
222
+ }
223
+
224
+ if (snapshots.length > 0) {
225
+ lines.push('Resolved snapshots (data/.recipe.json) — informational only:');
226
+ lines.push(
227
+ ' These are launch-time captures, not authoritative sources. Under the old defaults they',
228
+ ' include enables the operator never wrote. Audit the source recipe each run was launched',
229
+ ' from; the snapshot refreshes on the next launch from source.',
230
+ );
231
+ for (const a of snapshots) {
232
+ const enabled = AUDITED_MODULES.filter((m) => a.states[m] === 'explicit-enable');
233
+ const note = a.matchesOldDefaultBoilerplate
234
+ ? 'all three enabled — matches the old DEFAULT_RECIPE shape, almost certainly pre-flip defaults, not a choice'
235
+ : enabled.length > 0
236
+ ? `explicitly enabled here: ${enabled.join(', ')}`
237
+ : 'no audited modules enabled';
238
+ lines.push(` ${a.path} (recipe "${a.recipeName}"): ${note}`);
239
+ }
240
+ lines.push('');
241
+ }
242
+
243
+ return { text: lines.join('\n'), needsDecision };
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Main
248
+ // ---------------------------------------------------------------------------
249
+
250
+ function main(): void {
251
+ const args = process.argv.slice(2).filter((a) => !a.startsWith('--'));
252
+ if (args.length === 0) {
253
+ console.error('Usage: bun scripts/audit-module-optins.ts <recipe.json | directory> [...more]');
254
+ console.error('Report-only: reads recipes, changes nothing.');
255
+ process.exit(1);
256
+ }
257
+
258
+ const { audits, unreadable } = auditPaths(args);
259
+ for (const p of unreadable) console.error(`cannot read: ${p}`);
260
+
261
+ console.log('connectome-host module opt-in audit — report only, nothing is modified\n');
262
+ console.log(
263
+ 'Published ≤0.7.2 treated subagents/lessons/retrieval as opt-OUT (omitted = enabled).\n' +
264
+ 'Current main treats them as opt-IN (omitted = disabled). Explicit enables survive the\n' +
265
+ 'upgrade by design — this report shows where each recipe stands so you can decide which\n' +
266
+ 'of those are real choices and which are old boilerplate.\n',
267
+ );
268
+
269
+ let needsDecision = 0;
270
+ if (audits.length === 0) {
271
+ console.log('No recipe-shaped JSON found under the given paths.');
272
+ } else {
273
+ const report = renderReport(audits);
274
+ needsDecision = report.needsDecision;
275
+ console.log(report.text);
276
+ const sources = audits.filter((a) => !a.isSnapshot).length;
277
+ console.log(
278
+ `Summary: ${audits.length} recipe(s) audited (${sources} source, ${audits.length - sources} snapshot), ` +
279
+ `${needsDecision} item(s) need an operator decision.`,
280
+ );
281
+ }
282
+
283
+ // Unreadable path arguments outrank findings in the exit code.
284
+ if (unreadable.length > 0) process.exit(1);
285
+ if (needsDecision > 0) process.exit(2);
286
+ }
287
+
288
+ if (import.meta.main) main();
@@ -36,6 +36,7 @@ const PASSTHROUGH_KEYS: ReadonlyArray<keyof RecipeStrategy> = [
36
36
  'summaryContextLabel',
37
37
  'witnessedBeforeSequence',
38
38
  'witnessedInstruction',
39
+ 'identityReminder',
39
40
  ];
40
41
 
41
42
  export function buildFrameworkStrategy(
@@ -82,9 +83,17 @@ export function buildFrameworkStrategy(
82
83
  if (value !== undefined) autobiographicalOpts[key] = value;
83
84
  }
84
85
 
85
- // Autobiographical agents default to adaptive resolution unless a recipe
86
- // opts out; frontdesk keeps its historical hierarchical renderer default.
87
- if (strategyType === 'autobiographical' && autobiographicalOpts.adaptiveResolution === undefined) {
86
+ // Autobiographical AND frontdesk agents default to adaptive resolution
87
+ // unless a recipe opts out. Frontdesk historically kept the hierarchical
88
+ // renderer; that geometry has no tail reservation and no way to shed
89
+ // summary mass, so a long-lived agent saturates a fixed budget into a
90
+ // terminal context refusal (2026-08-03 boter clerk outage). The adaptive
91
+ // picker solves a frontier to fit — recipes can still pin
92
+ // `adaptiveResolution: false` as a rollback lever.
93
+ if (
94
+ (strategyType === 'autobiographical' || strategyType === 'frontdesk') &&
95
+ autobiographicalOpts.adaptiveResolution === undefined
96
+ ) {
88
97
  autobiographicalOpts.adaptiveResolution = true;
89
98
  }
90
99
 
@@ -99,7 +108,7 @@ export function buildFrameworkStrategy(
99
108
  // 'Claude', which voices self-recollections as a stranger for any agent
100
109
  // not named Claude. Summaries should speak as the agent itself.
101
110
  if (
102
- strategyType === 'autobiographical' &&
111
+ (strategyType === 'autobiographical' || strategyType === 'frontdesk') &&
103
112
  autobiographicalOpts.adaptiveResolution !== false &&
104
113
  autobiographicalOpts.foldingStrategy === undefined
105
114
  ) {
package/src/headless.ts CHANGED
@@ -136,6 +136,8 @@ export async function runHeadless(app: AppContext, argv: string[] = []): Promise
136
136
  'workspace-mounts-snapshot',
137
137
  'workspace-tree-snapshot',
138
138
  'workspace-file-snapshot',
139
+ 'cancel-subagent-result',
140
+ 'panel-response',
139
141
  ]);
140
142
 
141
143
  function emit(event: Record<string, unknown>): void {
@@ -329,6 +331,18 @@ export async function runHeadless(app: AppContext, argv: string[] = []): Promise
329
331
  }
330
332
  return;
331
333
  }
334
+ case 'panel-request': {
335
+ // Operator-panel query/mutation, shared verb. The SAME handler the
336
+ // WebUI host runs locally answers here, so parent and child views of
337
+ // any panel op can never drift. runPanelOp never throws — failures
338
+ // come back as {ok:false} and still produce a panel-response, which
339
+ // the parent's promise API requires to settle.
340
+ const op = typeof cmd.op === 'string' ? cmd.op : '';
341
+ const { runPanelOp } = await import('./web/panel-data.js');
342
+ const result = await runPanelOp(app, op, cmd.params ?? {});
343
+ emit({ type: 'panel-response', corrId: cmd.corrId, op, ...result });
344
+ return;
345
+ }
332
346
  case 'cancel-subagent': {
333
347
  const mod = app.framework.getAllModules().find((m) => m.name === 'subagent') as
334
348
  | { cancelSubagent(name: string): boolean }
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ import { readFileSync, existsSync } from 'node:fs';
37
37
  import { SubagentModule } from './modules/subagent-module.js';
38
38
  import { LessonsModule } from './modules/lessons-module.js';
39
39
  import { RetrievalModule } from './modules/retrieval-module.js';
40
+ import { buildRetrievalModuleConfig } from './retrieval-config.js';
40
41
  import type { RecipeWorkspaceMount } from './recipe.js';
41
42
  import { TuiModule } from './modules/tui-module.js';
42
43
  import { TimeModule } from './modules/time-module.js';
@@ -103,6 +104,10 @@ interface AppContext {
103
104
  branchState: BranchState;
104
105
  userMessageCount: number;
105
106
  codexAdapter?: CodexSubscriptionAdapter;
107
+ /** Content-free recent provider-call ledger. Consumed by the panel-data
108
+ * layer (health snapshots) in BOTH runtimes — WebUI host and headless
109
+ * fleet child. Null when the provider adapter exposes no ledger. */
110
+ callLedger: CallLedger | null;
106
111
 
107
112
  /** Stop current framework, switch to a different session, start new framework. */
108
113
  switchSession(id: string): Promise<void>;
@@ -227,16 +232,13 @@ async function createFramework(
227
232
  }
228
233
 
229
234
  // Retrieval (requires lessons). OPT-IN — not part of the standard recipe:
230
- // it injects context-dependent content into every compile (plus two Haiku
231
- // calls per turn), which adds per-turn context churn. Enable explicitly via
232
- // modules.retrieval only when an agent actually curates a lesson library.
235
+ // it injects context-dependent content into every compile (plus up to two
236
+ // configured retrieval-model calls), which adds per-turn context churn.
237
+ // Enable explicitly only when an agent actually curates a lesson library.
233
238
  if (modules.retrieval && lessonsModule) {
234
- const retrievalConfig = typeof modules.retrieval === 'object' ? modules.retrieval : {};
235
- moduleInstances.push(new RetrievalModule({
236
- membrane,
237
- retrievalModel: retrievalConfig.model,
238
- maxInjectedLessons: retrievalConfig.maxInjected,
239
- }));
239
+ moduleInstances.push(new RetrievalModule(
240
+ buildRetrievalModuleConfig(membrane, modules.retrieval, recipe.agent.provider),
241
+ ));
240
242
  }
241
243
 
242
244
  // Gate config — core AF EventGate feature.
@@ -996,6 +998,7 @@ async function main() {
996
998
  branchState: createBranchState(),
997
999
  userMessageCount: 0,
998
1000
  codexAdapter,
1001
+ callLedger,
999
1002
 
1000
1003
  async switchSession(id: string) {
1001
1004
  handleExport(this);
@@ -34,7 +34,7 @@ import { spawn as spawnProcess, type ChildProcess } from 'node:child_process';
34
34
  import { connect as netConnect, type Socket } from 'node:net';
35
35
  import { existsSync, mkdirSync, unlinkSync, openSync, closeSync, appendFileSync, realpathSync } from 'node:fs';
36
36
  import { join, resolve, isAbsolute } from 'node:path';
37
- import { type IncomingCommand, type WireEvent, matchesSubscription } from './fleet-types.js';
37
+ import { type IncomingCommand, type WireEvent, type PanelResponseEvent, matchesSubscription } from './fleet-types.js';
38
38
  import { loadRecipe } from '../recipe.js';
39
39
  import { REDUCER_REQUIRED_EVENTS } from '../state/agent-tree-reducer.js';
40
40
 
@@ -1621,6 +1621,65 @@ export class FleetModule implements Module {
1621
1621
  catch { return false; }
1622
1622
  }
1623
1623
 
1624
+ /** Monotonic corrId source for requestPanel. */
1625
+ private panelSeq = 0;
1626
+
1627
+ /**
1628
+ * Run one operator-panel op (see src/web/panel-data.ts) in a fleet child
1629
+ * and await its `panel-response`. Promise-based counterpart to the
1630
+ * fire-and-forget request* verbs above: HTTP proxy routes need to await a
1631
+ * body, and the WS handlers are simpler for it too.
1632
+ *
1633
+ * Never rejects — a dead child, send failure, or timeout resolves as
1634
+ * `{ok:false, error, status}` (502 unreachable, 504 timeout), so callers
1635
+ * translate straight into a response without try/catch.
1636
+ */
1637
+ requestPanel(
1638
+ childName: string,
1639
+ op: string,
1640
+ params?: Record<string, unknown>,
1641
+ timeoutMs = 30_000,
1642
+ ): Promise<{ ok: boolean; data?: unknown; error?: string; status?: number }> {
1643
+ const child = this.children.get(childName);
1644
+ if (!child || !child.socket) {
1645
+ return Promise.resolve({
1646
+ ok: false,
1647
+ error: child ? `child '${childName}' is ${child.status}, not running` : `unknown child: ${childName}`,
1648
+ status: child ? 502 : 404,
1649
+ });
1650
+ }
1651
+ const corrId = `panel-${op}-${++this.panelSeq}-${Date.now().toString(36)}`;
1652
+ return new Promise((resolvePanel) => {
1653
+ let settled = false;
1654
+ const finish = (result: { ok: boolean; data?: unknown; error?: string; status?: number }): void => {
1655
+ if (settled) return;
1656
+ settled = true;
1657
+ unsub();
1658
+ clearTimeout(timer);
1659
+ resolvePanel(result);
1660
+ };
1661
+ const unsub = this.onChildEvent(childName, (_name, evt) => {
1662
+ if (evt.type !== 'panel-response') return;
1663
+ const e = evt as unknown as PanelResponseEvent;
1664
+ if (e.corrId !== corrId) return;
1665
+ finish({
1666
+ ok: e.ok === true,
1667
+ ...(e.data !== undefined ? { data: e.data } : {}),
1668
+ ...(typeof e.error === 'string' ? { error: e.error } : {}),
1669
+ ...(typeof e.status === 'number' ? { status: e.status } : {}),
1670
+ });
1671
+ });
1672
+ const timer = setTimeout(() => {
1673
+ finish({ ok: false, error: `panel op '${op}' timed out after ${timeoutMs}ms (child '${childName}' unresponsive)`, status: 504 });
1674
+ }, timeoutMs);
1675
+ try {
1676
+ this.sendToChild(child, { type: 'panel-request', op, ...(params ? { params } : {}), corrId });
1677
+ } catch (err) {
1678
+ finish({ ok: false, error: `send to child failed: ${err instanceof Error ? err.message : String(err)}`, status: 502 });
1679
+ }
1680
+ });
1681
+ }
1682
+
1624
1683
  private async killChild(child: FleetChild): Promise<void> {
1625
1684
  if (child.status === 'exited' || child.status === 'crashed') return;
1626
1685
  const proc = child.process;
@@ -38,7 +38,18 @@ export type IncomingCommand =
38
38
  * `cancel-subagent-result`. The child looks the agent up in its own
39
39
  * SubagentModule, so this is the only way to stop a subagent that lives
40
40
  * in a fleet child rather than the conductor. */
41
- | { type: 'cancel-subagent'; name: string; corrId?: string };
41
+ | { type: 'cancel-subagent'; name: string; corrId?: string }
42
+ /**
43
+ * Run one operator-panel operation in the child (see PANEL_OPS in
44
+ * src/web/panel-data.ts: mcpl / settings(-update|-reset|-cancel-transition)
45
+ * / pins / pin-add / pin-remove / health / context-makeup /
46
+ * context-coverage / context-curve / context-preview / debug-context).
47
+ * Response is a single `panel-response` with the same corrId. One generic
48
+ * verb rather than a verb per panel: both ends dispatch through the SAME
49
+ * shared handler (`runPanelOp`), so a new panel surface needs no protocol
50
+ * change to work across the fleet.
51
+ */
52
+ | { type: 'panel-request'; op: string; params?: Record<string, unknown>; corrId?: string };
42
53
 
43
54
  // ---------------------------------------------------------------------------
44
55
  // Child → Parent: events
@@ -134,6 +145,23 @@ export interface CancelSubagentResultEvent {
134
145
  ts?: number;
135
146
  }
136
147
 
148
+ /** Response to a {type:'panel-request'} request. `data` is the same
149
+ * wire-shaped JSON the WebUI host serves locally for the given op;
150
+ * `ok:false` carries the error plus an HTTP-ish `status` so the parent's
151
+ * proxy routes can answer faithfully (404 unknown agent, 429 preview
152
+ * cooldown, 501 unsupported build). */
153
+ export interface PanelResponseEvent {
154
+ type: 'panel-response';
155
+ corrId?: string;
156
+ /** Echo of the requested op. */
157
+ op: string;
158
+ ok: boolean;
159
+ data?: unknown;
160
+ error?: string;
161
+ status?: number;
162
+ ts?: number;
163
+ }
164
+
137
165
  /** Response to a {type:'request-workspace-file'} request. */
138
166
  export interface WorkspaceFileSnapshotEvent {
139
167
  type: 'workspace-file-snapshot';
@@ -163,6 +191,7 @@ export type WireEvent =
163
191
  | WorkspaceTreeSnapshotEvent
164
192
  | WorkspaceFileSnapshotEvent
165
193
  | CancelSubagentResultEvent
194
+ | PanelResponseEvent
166
195
  // Arbitrary framework TraceEvent passthrough. The child stamps every emitted
167
196
  // event with `ts: Date.now()` in `emit()` (see headless.ts), so ts is always
168
197
  // present on the wire even when the underlying TraceEvent doesn't declare it.
@@ -118,8 +118,9 @@ export class McplAdminModule implements Module {
118
118
  {
119
119
  name: 'mcpl_list',
120
120
  description:
121
- 'List all MCPL servers: id, live connection status, tool count, command/url, ' +
122
- 'and where each is defined (recipe/file vs your own agent overlay).',
121
+ 'List all MCPL servers: connection/retry state, whether policy was established, ' +
122
+ 'the effective grant, masked/denied capability paths, host-command authority, ' +
123
+ 'tool count, target, and config source.',
123
124
  inputSchema: { type: 'object', properties: {} },
124
125
  },
125
126
  {
@@ -227,7 +228,19 @@ export class McplAdminModule implements Module {
227
228
 
228
229
  private handleList(): ToolResult {
229
230
  const framework = this.requireFramework();
230
- const live = framework.listMcplServers();
231
+ // These fields land in agent-framework 0.8's MCPL grant work. Keep them
232
+ // optional here so connectome-host remains truthful ("unknown") if it is
233
+ // temporarily run against an older framework package during rollout.
234
+ const live = framework.listMcplServers() as Array<
235
+ ReturnType<AgentFramework['listMcplServers']>[number] & {
236
+ retrying?: boolean;
237
+ policyEstablished?: boolean;
238
+ effectiveGrant?: string[];
239
+ maskedCapabilities?: string[];
240
+ deniedCapabilities?: string[];
241
+ allowHostCommands?: boolean;
242
+ }
243
+ >;
231
244
  const overlay = readAgentOverlay(this.overlayPath);
232
245
  const fileServers = readMcplServersFile(this.configPath);
233
246
 
@@ -237,8 +250,19 @@ export class McplAdminModule implements Module {
237
250
  ? 'agent-overlay'
238
251
  : s.id in fileServers ? 'file/recipe' : 'recipe';
239
252
  const target = s.command ?? s.url ?? '?';
253
+ const connectionState = s.connected ? 'CONNECTED' : s.retrying ? 'RETRYING' : 'DISCONNECTED';
254
+ const policyState = s.policyEstablished === undefined
255
+ ? 'unknown'
256
+ : s.policyEstablished ? 'established' : 'not-established';
257
+ const hostCommands = s.allowHostCommands === undefined
258
+ ? 'unknown'
259
+ : s.allowHostCommands ? 'allow' : 'deny';
240
260
  lines.push(
241
- `${s.id}: ${s.connected ? 'CONNECTED' : 'DISCONNECTED'} — ${s.toolCount} tools, ` +
261
+ `${s.id}: ${connectionState} — policy=${policyState}, ` +
262
+ `grant=${formatCapabilityList(s.effectiveGrant)}, ` +
263
+ `masked=${formatCapabilityList(s.maskedCapabilities)}, ` +
264
+ `denied=${formatCapabilityList(s.deniedCapabilities)}, ` +
265
+ `hostCommands=${hostCommands}; ${s.toolCount} tools, ` +
242
266
  `prefix=${s.toolPrefix}, source=${source}, ${target}`,
243
267
  );
244
268
  }
@@ -378,3 +402,8 @@ export class McplAdminModule implements Module {
378
402
  return ok(`Unloaded server "${id}" — its tools are gone from your toolset. ${persistNote}`);
379
403
  }
380
404
  }
405
+
406
+ function formatCapabilityList(paths: string[] | undefined): string {
407
+ if (paths === undefined) return 'unknown';
408
+ return `[${paths.join(',')}]`;
409
+ }