@animalabs/connectome-host 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +203 -10
  2. package/HEADLESS-FLEET-PLAN.md +22 -0
  3. package/README.md +22 -11
  4. package/docs/AGENT-ONBOARDING.md +20 -1
  5. package/docs/debug-context-api.md +2 -2
  6. package/docs/retrieval-traces.md +173 -0
  7. package/docs/webui-deployment.md +2 -1
  8. package/package.json +3 -3
  9. package/scripts/audit-module-optins.ts +288 -0
  10. package/scripts/warmup-session.ts +17 -3
  11. package/src/codex-subscription-adapter.ts +13 -1
  12. package/src/framework-agent-config.ts +59 -4
  13. package/src/framework-strategy.ts +33 -3
  14. package/src/headless.ts +14 -0
  15. package/src/index.ts +95 -35
  16. package/src/logging-adapter.ts +13 -2
  17. package/src/mcpl-config.ts +8 -0
  18. package/src/modules/fleet-module.ts +60 -1
  19. package/src/modules/fleet-types.ts +30 -1
  20. package/src/modules/identity-module.ts +274 -0
  21. package/src/modules/mcpl-admin-module.ts +78 -5
  22. package/src/modules/observers-module.ts +12 -0
  23. package/src/modules/retrieval-module.ts +254 -52
  24. package/src/modules/retrieval-trace-page.ts +254 -0
  25. package/src/modules/retrieval-trace.ts +904 -0
  26. package/src/modules/settings-module.ts +28 -2
  27. package/src/modules/subscription-gc-module.ts +54 -1
  28. package/src/modules/tts-relay-module.ts +33 -18
  29. package/src/modules/web-ui-module.ts +445 -894
  30. package/src/recipe.ts +137 -12
  31. package/src/retrieval-config.ts +39 -0
  32. package/src/strategies/frontdesk-strategy.ts +34 -125
  33. package/src/tui.ts +325 -54
  34. package/src/web/panel-data.ts +1187 -0
  35. package/src/web/protocol.ts +75 -10
  36. package/test/audit-module-optins.test.ts +167 -0
  37. package/test/bedrock-prompt-caching.test.ts +170 -0
  38. package/test/fleet-panel-request.test.ts +90 -0
  39. package/test/framework-strategy-defaults.test.ts +110 -0
  40. package/test/frontdesk-strategy.test.ts +25 -37
  41. package/test/headless-panel-request.test.ts +201 -0
  42. package/test/identity-and-surfaces.test.ts +157 -0
  43. package/test/mcpl-admin-module.test.ts +23 -0
  44. package/test/mock-headless-child.ts +14 -0
  45. package/test/retrieval-auth-loopback.test.ts +49 -0
  46. package/test/retrieval-config.test.ts +74 -0
  47. package/test/retrieval-module.test.ts +821 -0
  48. package/test/subscription-gc-module.test.ts +152 -0
  49. package/test/tui-format.test.ts +106 -0
  50. package/test/web-ui-context-coverage.test.ts +1 -1
  51. package/test/web-ui-module.test.ts +189 -3
  52. package/test/web-ui-observers.test.ts +8 -5
  53. package/test/web-ui-protocol.test.ts +0 -0
  54. package/web/bun.lock +345 -0
  55. package/web/src/App.tsx +159 -44
  56. package/web/src/Context.tsx +35 -8
  57. package/web/src/ContextDocument.tsx +20 -5
  58. package/web/src/Files.tsx +2 -8
  59. package/web/src/Lessons.tsx +2 -38
  60. package/web/src/Mcpl.tsx +80 -14
  61. package/web/src/Pins.tsx +5 -0
  62. package/web/src/Settings.tsx +5 -0
  63. package/web/vite.config.ts +8 -2
package/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';
@@ -46,6 +47,7 @@ import { SubscriptionGcModule } from './modules/subscription-gc-module.js';
46
47
  import { ChannelModeModule } from './modules/channel-mode-module.js';
47
48
  import { WebUiModule } from './modules/web-ui-module.js';
48
49
  import { ObserversModule } from './modules/observers-module.js';
50
+ import { IdentityModule } from './modules/identity-module.js';
49
51
  import { McplAdminModule } from './modules/mcpl-admin-module.js';
50
52
  import { TtsRelayModule } from './modules/tts-relay-module.js';
51
53
  import { loadMcplServers, applyAgentOverlay, DEFAULT_CONFIG_PATH, DEFAULT_AGENT_OVERLAY_PATH } from './mcpl-config.js';
@@ -62,7 +64,7 @@ import {
62
64
  parseRecipeArg,
63
65
  } from './recipe.js';
64
66
  import { createBranchState, resetBranchState, handleExport, type BranchState } from './commands.js';
65
- import { buildFrameworkAgentConfig } from './framework-agent-config.js';
67
+ import { buildFrameworkAgentConfig, membraneCachingOverride } from './framework-agent-config.js';
66
68
  import { buildFrameworkStrategy } from './framework-strategy.js';
67
69
  import { loadExtensions } from './extensions.js';
68
70
 
@@ -102,6 +104,10 @@ interface AppContext {
102
104
  branchState: BranchState;
103
105
  userMessageCount: number;
104
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;
105
111
 
106
112
  /** Stop current framework, switch to a different session, start new framework. */
107
113
  switchSession(id: string): Promise<void>;
@@ -146,6 +152,11 @@ async function resolveRecipe(): Promise<Recipe> {
146
152
  // Framework factory
147
153
  // ---------------------------------------------------------------------------
148
154
 
155
+ function resolveModel(recipe: Recipe): string {
156
+ return config.model || recipe.agent.model ||
157
+ (recipe.agent.provider === 'openai-codex' ? 'gpt-5.4' : 'claude-opus-4-6');
158
+ }
159
+
149
160
  async function createFramework(
150
161
  membrane: Membrane,
151
162
  storePath: string,
@@ -154,8 +165,7 @@ async function createFramework(
154
165
  settingsModule: SettingsModule,
155
166
  callLedger: CallLedger | null,
156
167
  ): Promise<AgentFramework> {
157
- const model = config.model || recipe.agent.model ||
158
- (recipe.agent.provider === 'openai-codex' ? 'gpt-5.4' : 'claude-opus-4-6');
168
+ const model = resolveModel(recipe);
159
169
  const modules = recipe.modules ?? {};
160
170
  const timeZone = resolveTimeZone(recipe.agent.timezone);
161
171
 
@@ -169,9 +179,10 @@ async function createFramework(
169
179
  // adapter can read its state for cross-cutting concerns like reasoning).
170
180
  const moduleInstances: Module[] = [new TuiModule(), new TimeModule(timeZone), settingsModule];
171
181
 
172
- // Subagents
182
+ // Subagents. OPT-IN — not part of the standard recipe; enable explicitly
183
+ // via modules.subagents when an agent should fork parallel workers.
173
184
  let subagentModule: SubagentModule | null = null;
174
- if (modules.subagents !== false) {
185
+ if (modules.subagents) {
175
186
  const subagentConfig = typeof modules.subagents === 'object' ? modules.subagents : {};
176
187
  subagentModule = new SubagentModule({
177
188
  parentAgentName: agentName,
@@ -181,9 +192,10 @@ async function createFramework(
181
192
  moduleInstances.push(subagentModule);
182
193
  }
183
194
 
184
- // Lessons
195
+ // Lessons. OPT-IN — not part of the standard recipe; enable explicitly via
196
+ // modules.lessons for agents that curate a lesson library.
185
197
  let lessonsModule: LessonsModule | null = null;
186
- if (modules.lessons !== false) {
198
+ if (modules.lessons) {
187
199
  const globalLessonsPath = resolve(join(storePath, '..', '..', 'lessons.json'));
188
200
  lessonsModule = new LessonsModule({ globalPath: globalLessonsPath });
189
201
  moduleInstances.push(lessonsModule);
@@ -219,14 +231,14 @@ async function createFramework(
219
231
  moduleInstances.push(new FleetModule(fleetModuleConfig));
220
232
  }
221
233
 
222
- // Retrieval (requires lessons)
223
- if (modules.retrieval !== false && lessonsModule) {
224
- const retrievalConfig = typeof modules.retrieval === 'object' ? modules.retrieval : {};
225
- moduleInstances.push(new RetrievalModule({
226
- membrane,
227
- retrievalModel: retrievalConfig.model,
228
- maxInjectedLessons: retrievalConfig.maxInjected,
229
- }));
234
+ // Retrieval (requires lessons). OPT-IN — not part of the standard recipe:
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.
238
+ if (modules.retrieval && lessonsModule) {
239
+ moduleInstances.push(new RetrievalModule(
240
+ buildRetrievalModuleConfig(membrane, modules.retrieval, recipe.agent.provider),
241
+ ));
230
242
  }
231
243
 
232
244
  // Gate config — core AF EventGate feature.
@@ -332,11 +344,27 @@ async function createFramework(
332
344
  // MCPL self-administration — opt-in per recipe (grants the agent the
333
345
  // ability to spawn arbitrary commands via mcpl_deploy; see recipe.ts).
334
346
  let mcplAdminModule: McplAdminModule | null = null;
335
- if (modules.mcplAdmin === true) {
336
- mcplAdminModule = new McplAdminModule({ timeZone });
347
+ if (modules.mcplAdmin === true || typeof modules.mcplAdmin === 'object') {
348
+ const surface = typeof modules.mcplAdmin === 'object' ? modules.mcplAdmin.surface : undefined;
349
+ mcplAdminModule = new McplAdminModule({ timeZone, ...(surface ? { surface } : {}) });
337
350
  moduleInstances.push(mcplAdminModule);
338
351
  }
339
352
 
353
+ // Archipelago identity — opt-in per recipe. Utilities-only: enrollment is
354
+ // one-time and token refresh is rare, so it costs no tool slots (see
355
+ // identity-module.ts header). Keypair lives at the dataDir level — an
356
+ // identity belongs to the deployment, not the session.
357
+ let identityModule: IdentityModule | null = null;
358
+ if (modules.identity !== undefined && modules.identity !== false) {
359
+ const idCfg = typeof modules.identity === 'object' ? modules.identity : {};
360
+ identityModule = new IdentityModule({
361
+ keyPath: process.env.IDENTITY_KEY_FILE || resolve(config.dataDir, 'identity-key.pem'),
362
+ home: idCfg.home ?? process.env.IDENTITY_HOME ?? 'id.animalabs.ai',
363
+ ...(idCfg.audience ? { defaultAudience: idCfg.audience } : {}),
364
+ });
365
+ moduleInstances.push(identityModule);
366
+ }
367
+
340
368
  // Web admin UI — opt-in per recipe
341
369
  let webUiModule: WebUiModule | null = null;
342
370
  if (modules.webui !== undefined && modules.webui !== false) {
@@ -356,7 +384,10 @@ async function createFramework(
356
384
  ...(callLedger ? { callLedger } : {}),
357
385
  });
358
386
  moduleInstances.push(webUiModule);
359
- moduleInstances.push(new ObserversModule({ path: observersPath }));
387
+ moduleInstances.push(new ObserversModule({
388
+ path: observersPath,
389
+ ...(webuiConfig.observersSurface ? { surface: webuiConfig.observersSurface } : {}),
390
+ }));
360
391
  }
361
392
 
362
393
  // TTS relay tap — opt-in per recipe. Pure trace-bus consumer: mirrors the
@@ -415,6 +446,7 @@ async function createFramework(
415
446
  if (recipeEntry.url !== undefined) merged.url = recipeEntry.url;
416
447
  if (recipeEntry.transport !== undefined) merged.transport = recipeEntry.transport;
417
448
  if (recipeEntry.token !== undefined) merged.token = recipeEntry.token;
449
+ if (recipeEntry.access !== undefined) merged.access = recipeEntry.access;
418
450
  allServers.push(merged as { id: string; command?: string; url?: string; [k: string]: unknown });
419
451
  } else if (recipeEntry.command || recipeEntry.url) {
420
452
  // Recipe-defined server (not in the file config). Spread ALL recipe fields
@@ -426,12 +458,30 @@ async function createFramework(
426
458
  // Apply the agent overlay (mcpl-servers.agent.json): servers the agent
427
459
  // deployed for itself load unconditionally (no recipe opt-in), and
428
460
  // tombstones suppress recipe/file servers the agent unloaded.
429
- const finalServers = applyAgentOverlay(allServers, DEFAULT_AGENT_OVERLAY_PATH).map((server) => ({
430
- ...server,
431
- // Stdio MCPL children inherit a single agent-facing wall clock. Protocol
432
- // timestamps remain UTC; only their rendered text uses this setting.
433
- env: { ...(server.env ?? {}), AGENT_TIMEZONE: timeZone },
434
- }));
461
+ const finalServers = applyAgentOverlay(allServers, DEFAULT_AGENT_OVERLAY_PATH).map((server) => {
462
+ const withEnv: { id: string; command?: string; url?: string; [k: string]: unknown } = {
463
+ ...server,
464
+ // Stdio MCPL children inherit a single agent-facing wall clock. Protocol
465
+ // timestamps remain UTC; only their rendered text uses this setting.
466
+ env: { ...(server.env ?? {}), AGENT_TIMEZONE: timeZone },
467
+ };
468
+ // `access` is a declarative name (recipe/file/overlay); the credential
469
+ // provider it implies is attached HERE, at load time — fresh credential
470
+ // per dial via the identity module, never serialized, never in model
471
+ // context (see identity-module.ts header).
472
+ if (typeof withEnv.access === 'string' && withEnv.access) {
473
+ if (identityModule) {
474
+ const identity = identityModule;
475
+ const audience = withEnv.access as string;
476
+ withEnv.accessProvider = () => identity.accessFor(audience);
477
+ } else {
478
+ console.error(
479
+ `[mcpl] server "${server.id}": access "${withEnv.access}" declared but the recipe has no identity module — connecting without credentials`,
480
+ );
481
+ }
482
+ }
483
+ return withEnv;
484
+ });
435
485
 
436
486
  // No server augmentation needed — gate is wired via FrameworkConfig.gate
437
487
 
@@ -513,6 +563,7 @@ agents: [agentConfig],
513
563
 
514
564
  if (mcplAdminModule) {
515
565
  mcplAdminModule.setFramework(framework);
566
+ if (identityModule) mcplAdminModule.setIdentity(identityModule);
516
567
  }
517
568
 
518
569
  if (workspaceModule) {
@@ -827,13 +878,18 @@ async function main() {
827
878
  xTitle: recipe.agent.name ?? recipe.name,
828
879
  })
829
880
  : undefined;
830
- // Bedrock: legacy Claude models (3.5 Sonnet 0620/1022, Opus 3) that have
831
- // left the Anthropic API but survive on AWS. The adapter reads AWS_* env
832
- // vars (AWS_REGION defaults us-west-2) and maps standard Claude model IDs
833
- // to Bedrock IDs (explicit map + `anthropic.<id>-v1:0` fallback). Uses the
834
- // Anthropic-native message shape, so NativeFormatter applies unchanged.
835
- // No CallLedger: prompt caching is rejected outright by legacy Bedrock
836
- // models (tested 2026-07-21). Wrapped for llm-calls.jsonl visibility.
881
+ // Bedrock: an alternate Claude transport. (Historically for models that
882
+ // left the direct API as of 2026-07-31 every 3.5-era id and opus-4-0514
883
+ // are EOL on Bedrock too, so what actually runs here is the 4-era via
884
+ // inference profiles.) The adapter reads AWS_* env vars (AWS_REGION
885
+ // defaults us-west-2) and maps standard Claude model IDs to Bedrock IDs.
886
+ // Uses the Anthropic-native message shape, so NativeFormatter applies
887
+ // unchanged. Prompt caching is model-gated
888
+ // (bedrockModelSupportsPromptCaching): pre-GA families (Claude 3,
889
+ // 3.5 Sonnet) off, everything currently invokable caches — verified by
890
+ // live probe 2026-07-31. Still no CallLedger — it's
891
+ // anthropic-transport-only for now; cache metrics are visible in
892
+ // llm-calls.jsonl via the logging wrapper.
837
893
  const bedrockAdapter = provider === 'bedrock'
838
894
  ? new LoggingBedrockAdapter({}, llmLogPath)
839
895
  : undefined;
@@ -915,10 +971,13 @@ async function main() {
915
971
  : recipe.agent.formatter === 'anthropic-xml'
916
972
  ? new AnthropicXmlFormatter()
917
973
  : new NativeFormatter(),
918
- // Bedrock legacy Claude models 400 on any cache_control block
919
- // ("your request did not allow prompt caching") — suppress the
920
- // historical promptCaching=true default on that transport.
921
- ...(provider === 'bedrock' ? { defaultPromptCaching: false } : {}),
974
+ // Caching default for internal callers (autobio compression,
975
+ // executeMerge), which read Membrane's defaultPromptCaching rather
976
+ // than the per-agent flag. Applies on EVERY provider whenever the
977
+ // recipe/model resolves an explicit answer an Anthropic recipe with
978
+ // promptCaching:false must disable internal calls too, and on bedrock
979
+ // the model gate decides (see bedrockModelSupportsPromptCaching).
980
+ ...membraneCachingOverride(recipe, resolveModel(recipe)),
922
981
  // Anchor the assistant role for internal callers that don't set
923
982
  // request.assistantParticipant themselves (autobio compression,
924
983
  // executeMerge). Mismatch here flips stored assistant turns to
@@ -939,6 +998,7 @@ async function main() {
939
998
  branchState: createBranchState(),
940
999
  userMessageCount: 0,
941
1000
  codexAdapter,
1001
+ callLedger,
942
1002
 
943
1003
  async switchSession(id: string) {
944
1004
  handleExport(this);
@@ -32,7 +32,11 @@ import { summarizeCacheControls, type ProviderCallRecord } from './call-ledger.j
32
32
  /** Live read of the current reasoning setting. The host wires this to
33
33
  * `SettingsModule.getReasoning()` so toggles via the `agent_settings` tool's
34
34
  * reasoning_enabled field take effect on the next call without restart. */
35
- export type ReasoningGetter = () => { enabled: boolean; budgetTokens: number };
35
+ export type ReasoningGetter = () => {
36
+ enabled: boolean;
37
+ budgetTokens: number;
38
+ display?: 'summarized' | 'omitted';
39
+ };
36
40
  export type ProviderCallObserver = (record: ProviderCallRecord) => void;
37
41
 
38
42
  /** Exact first-system-block identity Anthropic requires on subscription
@@ -119,9 +123,16 @@ export class LoggingAnthropicAdapter extends AnthropicAdapter {
119
123
  // request.extra; Object.assign(params, rest)`), so we route thinking
120
124
  // through the typed `extra` bag — no type assertion, and it survives the
121
125
  // next dependency reshuffle instead of hiding it from the compiler.
126
+ //
127
+ // `display`: models 4.7+ default to 'omitted' (empty `thinking` text,
128
+ // signature only). We pass the setting through — default 'summarized' —
129
+ // so reasoning summaries are visible again (stores, webui, estimators).
122
130
  return {
123
131
  ...request,
124
- extra: { ...request.extra, thinking: { type: 'adaptive' } },
132
+ extra: {
133
+ ...request.extra,
134
+ thinking: { type: 'adaptive', display: r.display ?? 'summarized' },
135
+ },
125
136
  };
126
137
  }
127
138
 
@@ -27,6 +27,14 @@ export interface ServerFileEntry {
27
27
  disabledTools?: string[];
28
28
  /** @deprecated One-time migration input for legacy installations. */
29
29
  channelSubscription?: 'auto' | 'manual' | string[];
30
+ /**
31
+ * Name of a network access grant (an archipelago audience, e.g.
32
+ * "eidoverse"). Purely declarative here: at load/deploy time the host
33
+ * attaches a credential provider that fetches something fresh on every
34
+ * dial via the identity module. The agent (and this file) never holds a
35
+ * credential — `access` is a name, not a secret.
36
+ */
37
+ access?: string;
30
38
  }
31
39
 
32
40
  export interface McplServersFile {
@@ -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.