@animalabs/connectome-host 0.7.3 → 0.8.0

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 (97) hide show
  1. package/.env.example +12 -5
  2. package/.github/PULL_REQUEST_TEMPLATE.md +3 -2
  3. package/.github/workflows/changelog.yml +9 -4
  4. package/.github/workflows/ci.yml +5 -3
  5. package/.github/workflows/publish.yml +12 -6
  6. package/CHANGELOG.md +401 -10
  7. package/CONTRIBUTING.md +47 -19
  8. package/HEADLESS-FLEET-PLAN.md +22 -0
  9. package/README.md +39 -1
  10. package/bun.lock +27 -31
  11. package/changelog.d/README.md +28 -0
  12. package/docs/AGENT-ONBOARDING.md +1 -1
  13. package/docs/debug-context-api.md +2 -2
  14. package/docs/retrieval-traces.md +173 -0
  15. package/docs/webui-deployment.md +2 -1
  16. package/package.json +6 -6
  17. package/recipes/SETUP.md +11 -5
  18. package/recipes/TRIUMVIRATE-SETUP.md +68 -14
  19. package/recipes/knowledge-miner.json +0 -30
  20. package/recipes/mock-test.json +19 -0
  21. package/recipes/triumvirate.json +6 -1
  22. package/scripts/audit-module-optins.ts +288 -0
  23. package/scripts/release-changelog.ts +210 -21
  24. package/src/cache-keepalive-log.ts +41 -0
  25. package/src/commands.ts +96 -0
  26. package/src/framework-strategy.ts +50 -4
  27. package/src/gate-telemetry.ts +106 -0
  28. package/src/headless.ts +24 -0
  29. package/src/index.ts +179 -64
  30. package/src/mcpl-config.ts +99 -1
  31. package/src/modules/fleet-module.ts +60 -1
  32. package/src/modules/fleet-types.ts +30 -1
  33. package/src/modules/identity-module.ts +310 -2
  34. package/src/modules/instructions-module.ts +265 -0
  35. package/src/modules/mcpl-admin-module.ts +89 -13
  36. package/src/modules/retrieval-module.ts +249 -51
  37. package/src/modules/retrieval-trace-page.ts +254 -0
  38. package/src/modules/retrieval-trace.ts +904 -0
  39. package/src/modules/subagent-module.ts +18 -0
  40. package/src/modules/tts-relay-module.ts +33 -18
  41. package/src/modules/web-ui-module.ts +445 -894
  42. package/src/recipe.ts +787 -29
  43. package/src/retrieval-config.ts +39 -0
  44. package/src/strategies/frontdesk-strategy.ts +34 -125
  45. package/src/tui.ts +325 -54
  46. package/src/web/panel-data.ts +1206 -0
  47. package/src/web/protocol.ts +75 -10
  48. package/src/workspace-mounts.ts +73 -0
  49. package/test/audit-module-optins.test.ts +174 -0
  50. package/test/cache-keepalive-log.test.ts +83 -0
  51. package/test/conversations-recipe.test.ts +142 -0
  52. package/test/fleet-panel-request.test.ts +90 -0
  53. package/test/framework-fkm-composition.test.ts +35 -3
  54. package/test/framework-strategy-defaults.test.ts +41 -0
  55. package/test/frontdesk-strategy.test.ts +25 -37
  56. package/test/gate-telemetry-adapter.test.ts +84 -0
  57. package/test/gate-telemetry.test.ts +91 -0
  58. package/test/headless-panel-request.test.ts +201 -0
  59. package/test/identity-and-surfaces.test.ts +212 -1
  60. package/test/instructions-module.test.ts +258 -0
  61. package/test/mcpl-admin-module.test.ts +64 -0
  62. package/test/mcpl-agent-overlay.test.ts +51 -3
  63. package/test/mcpl-child-env.test.ts +64 -0
  64. package/test/mock-headless-child.ts +14 -0
  65. package/test/nudge-command.test.ts +47 -0
  66. package/test/recipe-cache-keepalive.test.ts +59 -0
  67. package/test/recipe-compression-fallback.test.ts +19 -0
  68. package/test/recipe-hybrid-prose-routing.test.ts +12 -0
  69. package/test/recipe-instructions.test.ts +176 -0
  70. package/test/recipe-kv-unified.test.ts +87 -0
  71. package/test/recipe-mcp-source.test.ts +54 -0
  72. package/test/recipe-openai-compatible.test.ts +54 -0
  73. package/test/recipe-path-resolution.test.ts +19 -8
  74. package/test/recipe-provider.test.ts +14 -0
  75. package/test/recipe-save-unresolved.test.ts +244 -0
  76. package/test/recipe-source-only.test.ts +38 -0
  77. package/test/release-changelog.test.ts +202 -0
  78. package/test/retrieval-auth-loopback.test.ts +49 -0
  79. package/test/retrieval-config.test.ts +74 -0
  80. package/test/retrieval-module.test.ts +821 -0
  81. package/test/subagent-prose-routing.test.ts +109 -0
  82. package/test/tui-format.test.ts +106 -0
  83. package/test/web-ui-context-coverage.test.ts +1 -1
  84. package/test/web-ui-module.test.ts +189 -3
  85. package/test/web-ui-observers.test.ts +8 -5
  86. package/test/web-ui-protocol.test.ts +0 -0
  87. package/test/workspace-mounts.test.ts +68 -0
  88. package/web/src/App.tsx +160 -44
  89. package/web/src/Context.tsx +35 -8
  90. package/web/src/ContextDocument.tsx +20 -5
  91. package/web/src/Files.tsx +2 -8
  92. package/web/src/Health.tsx +61 -1
  93. package/web/src/Lessons.tsx +2 -38
  94. package/web/src/Mcpl.tsx +80 -14
  95. package/web/src/Pins.tsx +5 -0
  96. package/web/src/Settings.tsx +5 -0
  97. package/web/vite.config.ts +8 -2
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * bun src/index.ts --headless --exit-when-idle # One-shot: exit when agents go idle after first inference
11
11
  *
12
12
  * Environment variables:
13
- * ANTHROPIC_API_KEY - Required
13
+ * ANTHROPIC_API_KEY - Required (not needed for recipe provider "mock")
14
14
  * MODEL - Override model (default: from recipe or claude-opus-4-6)
15
15
  * DATA_DIR - Data directory for sessions (default: ./data)
16
16
  */
@@ -19,25 +19,28 @@ import {
19
19
  AnthropicXmlFormatter,
20
20
  BedrockAdapter,
21
21
  Membrane,
22
+ MockAdapter,
22
23
  NativeFormatter,
23
24
  OpenAIResponsesAPIAdapter,
24
25
  OpenAIResponsesFormatter,
26
+ OpenAICompatibleAdapter,
25
27
  OpenRouterAdapter,
26
28
  } from '@animalabs/membrane';
27
29
  import { LoggingAnthropicAdapter } from './logging-adapter.js';
28
30
  import { LoggingProviderAdapter } from './logging-provider-wrapper.js';
31
+ import { gateTelemetryHeaders, type TurnTrigger } from './gate-telemetry.js';
29
32
  import { LoggingBedrockAdapter } from './logging-bedrock-adapter.js';
30
33
  import { CodexSubscriptionAdapter } from './codex-subscription-adapter.js';
31
34
  import { CallLedger } from './call-ledger.js';
32
35
  import { SettingsModule } from './modules/settings-module.js';
33
- import { AgentFramework, WorkspaceModule, resolveTimeZone, type Module, type MountConfig } from '@animalabs/agent-framework';
36
+ import { AgentFramework, WorkspaceModule, resolveTimeZone, type Module } from '@animalabs/agent-framework';
34
37
  import { resolve, join, basename } from 'node:path';
35
38
  import { appendFile, mkdir, stat, rename } from 'node:fs/promises';
36
39
  import { readFileSync, existsSync } from 'node:fs';
37
40
  import { SubagentModule } from './modules/subagent-module.js';
38
41
  import { LessonsModule } from './modules/lessons-module.js';
39
42
  import { RetrievalModule } from './modules/retrieval-module.js';
40
- import type { RecipeWorkspaceMount } from './recipe.js';
43
+ import { buildRetrievalModuleConfig } from './retrieval-config.js';
41
44
  import { TuiModule } from './modules/tui-module.js';
42
45
  import { TimeModule } from './modules/time-module.js';
43
46
  import { FleetModule, type FleetModuleConfig } from './modules/fleet-module.js';
@@ -49,14 +52,15 @@ import { ObserversModule } from './modules/observers-module.js';
49
52
  import { IdentityModule } from './modules/identity-module.js';
50
53
  import { McplAdminModule } from './modules/mcpl-admin-module.js';
51
54
  import { TtsRelayModule } from './modules/tts-relay-module.js';
52
- import { loadMcplServers, applyAgentOverlay, DEFAULT_CONFIG_PATH, DEFAULT_AGENT_OVERLAY_PATH } from './mcpl-config.js';
55
+ import { InstructionsModule } from './modules/instructions-module.js';
56
+ import { loadMcplServers, applyAgentOverlay, composeMcplChildEnv, DEFAULT_CONFIG_PATH, DEFAULT_AGENT_OVERLAY_PATH } from './mcpl-config.js';
53
57
  import { SessionManager } from './session-manager.js';
54
58
  import { resolveAgentName } from './agent-name.js';
55
59
  import { generateSessionName } from './synesthete.js';
56
60
  import {
57
61
  type Recipe,
58
62
  DEFAULT_RECIPE,
59
- loadRecipe,
63
+ loadRecipeDetailed,
60
64
  saveRecipe,
61
65
  loadSavedRecipe,
62
66
  clearSavedRecipe,
@@ -64,7 +68,9 @@ import {
64
68
  } from './recipe.js';
65
69
  import { createBranchState, resetBranchState, handleExport, type BranchState } from './commands.js';
66
70
  import { buildFrameworkAgentConfig, membraneCachingOverride } from './framework-agent-config.js';
67
- import { buildFrameworkStrategy } from './framework-strategy.js';
71
+ import { buildFrameworkStrategy, buildConversationsConfig } from './framework-strategy.js';
72
+ import { buildWorkspaceMounts } from './workspace-mounts.js';
73
+ import { logKeepaliveEvent } from './cache-keepalive-log.js';
68
74
  import { loadExtensions } from './extensions.js';
69
75
 
70
76
  export type { AppContext };
@@ -79,6 +85,10 @@ const config = {
79
85
  authToken: process.env.ANTHROPIC_AUTH_TOKEN,
80
86
  openaiApiKey: process.env.OPENAI_API_KEY,
81
87
  openrouterApiKey: process.env.OPENROUTER_API_KEY,
88
+ // Deliberately NO fallback to OPENAI_API_KEY: agent.baseUrl is
89
+ // recipe-controlled, so a fallback would silently send a real OpenAI
90
+ // credential as a Bearer token to whatever endpoint a recipe names.
91
+ openaiCompatibleApiKey: process.env.OPENAI_COMPATIBLE_API_KEY,
82
92
  codexBinary: process.env.CODEX_BINARY,
83
93
  model: process.env.MODEL,
84
94
  dataDir: process.env.DATA_DIR || './data',
@@ -103,6 +113,10 @@ interface AppContext {
103
113
  branchState: BranchState;
104
114
  userMessageCount: number;
105
115
  codexAdapter?: CodexSubscriptionAdapter;
116
+ /** Content-free recent provider-call ledger. Consumed by the panel-data
117
+ * layer (health snapshots) in BOTH runtimes — WebUI host and headless
118
+ * fleet child. Null when the provider adapter exposes no ledger. */
119
+ callLedger: CallLedger | null;
106
120
 
107
121
  /** Stop current framework, switch to a different session, start new framework. */
108
122
  switchSession(id: string): Promise<void>;
@@ -123,8 +137,10 @@ async function resolveRecipe(): Promise<Recipe> {
123
137
 
124
138
  if (source) {
125
139
  try {
126
- const recipe = await loadRecipe(source);
127
- saveRecipe(config.dataDir, recipe);
140
+ // Save the unresolved (pre-substitution) form: $DATA_DIR is typically
141
+ // host-mounted and backed up, so resolved secrets must never land there.
142
+ const { recipe, persistable } = await loadRecipeDetailed(source);
143
+ saveRecipe(config.dataDir, persistable);
128
144
  console.log(`Loaded recipe: ${recipe.name}${recipe.description ? ` — ${recipe.description}` : ''}`);
129
145
  return recipe;
130
146
  } catch (err) {
@@ -133,11 +149,21 @@ async function resolveRecipe(): Promise<Recipe> {
133
149
  }
134
150
  }
135
151
 
136
- // Try saved recipe
137
- const saved = loadSavedRecipe(config.dataDir);
138
- if (saved) {
139
- console.log(`Resuming recipe: ${saved.name}`);
140
- return saved;
152
+ // Try saved recipe. Re-resolves ${VAR} references against the CURRENT
153
+ // environment, so a missing secret fails loudly here rather than starting
154
+ // a misconfigured agent on the default recipe.
155
+ try {
156
+ const saved = await loadSavedRecipe(config.dataDir);
157
+ if (saved) {
158
+ console.log(`Resuming recipe: ${saved.name}`);
159
+ return saved;
160
+ }
161
+ } catch (err) {
162
+ console.error(
163
+ `Failed to resume saved recipe from ${config.dataDir}:`,
164
+ err instanceof Error ? err.message : err,
165
+ );
166
+ process.exit(1);
141
167
  }
142
168
 
143
169
  return DEFAULT_RECIPE;
@@ -227,16 +253,13 @@ async function createFramework(
227
253
  }
228
254
 
229
255
  // 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.
256
+ // it injects context-dependent content into every compile (plus up to two
257
+ // configured retrieval-model calls), which adds per-turn context churn.
258
+ // Enable explicitly only when an agent actually curates a lesson library.
233
259
  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
- }));
260
+ moduleInstances.push(new RetrievalModule(
261
+ buildRetrievalModuleConfig(membrane, modules.retrieval, recipe.agent.provider),
262
+ ));
240
263
  }
241
264
 
242
265
  // Gate config — core AF EventGate feature.
@@ -258,48 +281,30 @@ async function createFramework(
258
281
  // Note: workspace: false disables ALL filesystem access (both read and write).
259
282
  // Previously LocalFilesModule was always-on; this is an intentional change —
260
283
  // recipes that need read-only access should keep workspace enabled (the default).
284
+ // Mount construction lives in workspace-mounts.ts so recipe validation
285
+ // reasons over the SAME mount flags the runtime builds (see that file's
286
+ // header for why).
261
287
  let workspaceModule: WorkspaceModule | null = null;
262
- if (modules.workspace !== false) {
263
- let mounts: MountConfig[];
264
- if (typeof modules.workspace === 'object' && modules.workspace.mounts) {
265
- // Only pass fields the recipe explicitly provides; let WorkspaceModule default the rest.
266
- // We override watch to 'never' since FKM doesn't need chokidar filesystem watchers.
267
- mounts = modules.workspace.mounts.map((m: RecipeWorkspaceMount) => {
268
- const mount: MountConfig = {
269
- name: m.name,
270
- path: resolve(m.path),
271
- mode: m.mode ?? 'read-write',
272
- watch: m.watch ?? 'never', // FKM: no chokidar watchers by default
273
- };
274
- if (m.ignore) mount.ignore = m.ignore;
275
- if (m.wakeOnChange !== undefined) mount.wakeOnChange = m.wakeOnChange;
276
- if (m.autoMaterialize !== undefined) mount.autoMaterialize = m.autoMaterialize;
277
- return mount;
278
- });
279
- } else {
280
- // Default: read-only input mount + read-write products mount
281
- mounts = [
282
- { name: 'input', path: resolve('./input'), mode: 'read-only', watch: 'never' },
283
- { name: 'products', path: resolve('./output'), mode: 'read-write', watch: 'never' },
284
- ];
285
- }
286
-
287
- // Config mount: version-controls gate.json (and future config files) via Chronicle.
288
- // Opt-in via recipe: workspace.configMount = true
289
- const wantConfigMount = typeof modules.workspace === 'object' && modules.workspace.configMount;
290
- if (wantConfigMount) {
291
- mounts.push({
292
- name: '_config',
293
- path: resolve(join(storePath, 'config')),
294
- mode: 'read-write',
295
- watch: 'always',
296
- });
297
- }
298
-
299
- workspaceModule = new WorkspaceModule({ mounts });
288
+ const workspaceMounts = buildWorkspaceMounts(modules.workspace, storePath);
289
+ if (workspaceMounts) {
290
+ workspaceModule = new WorkspaceModule({ mounts: workspaceMounts });
300
291
  moduleInstances.push(workspaceModule);
301
292
  }
302
293
 
294
+ // Shared operating instructions — opt-in per recipe. Injects a living
295
+ // instructions document (read through a workspace mount) into EVERY
296
+ // agent's context on every turn — resident and ephemeral subagents alike —
297
+ // via gatherContext. Requires the workspace module; validateRecipe rejects
298
+ // the instructions+`workspace: false` pairing, so workspaceModule is
299
+ // non-null here (the guard keeps the module fail-open regardless).
300
+ if (modules.instructions) {
301
+ const instructionsConfig =
302
+ typeof modules.instructions === 'object' ? modules.instructions : {};
303
+ const instructionsModule = new InstructionsModule(instructionsConfig);
304
+ if (workspaceModule) instructionsModule.setWorkspace(workspaceModule);
305
+ moduleInstances.push(instructionsModule);
306
+ }
307
+
303
308
  // Activity (typing indicators) — opt-in per recipe
304
309
  let activityModule: ActivityModule | null = null;
305
310
  if (modules.activity !== undefined && modules.activity !== false) {
@@ -459,9 +464,11 @@ async function createFramework(
459
464
  const finalServers = applyAgentOverlay(allServers, DEFAULT_AGENT_OVERLAY_PATH).map((server) => {
460
465
  const withEnv: { id: string; command?: string; url?: string; [k: string]: unknown } = {
461
466
  ...server,
462
- // Stdio MCPL children inherit a single agent-facing wall clock. Protocol
463
- // timestamps remain UTC; only their rendered text uses this setting.
464
- env: { ...(server.env ?? {}), AGENT_TIMEZONE: timeZone },
467
+ // Stdio MCPL children inherit a single agent-facing wall clock plus the
468
+ // framework's refusal-annotation suppression baseline (operator env
469
+ // supersedes the baseline; see composeMcplChildEnv). Protocol
470
+ // timestamps remain UTC; only their rendered text uses AGENT_TIMEZONE.
471
+ env: composeMcplChildEnv(server.env as Record<string, string> | undefined, timeZone),
465
472
  };
466
473
  // `access` is a declarative name (recipe/file/overlay); the credential
467
474
  // provider it implies is attached HERE, at load time — fresh credential
@@ -487,6 +494,10 @@ async function createFramework(
487
494
  const strategy = buildFrameworkStrategy(recipe, model, timeZone, extensionRegistry);
488
495
  const agentConfig = buildFrameworkAgentConfig(recipe, agentName, model, strategy);
489
496
 
497
+ // Per-channel conversation routing: the recipe agent becomes the trunk
498
+ // template; forks get a fresh instance of the same recipe strategy.
499
+ const conversations = buildConversationsConfig(recipe, agentName, model, timeZone, extensionRegistry);
500
+
490
501
  // -- Create framework --
491
502
  const framework = await AgentFramework.create({
492
503
  storePath,
@@ -498,6 +509,7 @@ agents: [agentConfig],
498
509
  timeZone,
499
510
  // Client-side programmatic tool calling (code_execution) — recipe opt-in.
500
511
  ...(recipe.codeExecution ? { codeExecution: recipe.codeExecution } : {}),
512
+ ...(conversations ? { conversations } : {}),
501
513
  });
502
514
 
503
515
  // Wire post-creation hooks
@@ -870,6 +882,16 @@ async function main() {
870
882
  fastMode: recipe.agent.codex?.fastMode ?? false,
871
883
  })
872
884
  : undefined;
885
+ // Generic OpenAI-compatible chat-completions endpoint (Ollama, vLLM, Together,
886
+ // Groq, NanoGPT, ...). The recipe carries the endpoint (agent.baseUrl,
887
+ // validated at load); the key is optional because local servers have none.
888
+ const openaiCompatibleAdapter = provider === 'openai-compatible'
889
+ ? new OpenAICompatibleAdapter({
890
+ baseURL: recipe.agent.baseUrl!,
891
+ apiKey: config.openaiCompatibleApiKey || undefined,
892
+ providerName: 'openai-compatible',
893
+ })
894
+ : undefined;
873
895
  const openrouterAdapter = provider === 'openrouter'
874
896
  ? new OpenRouterAdapter({
875
897
  apiKey: config.openrouterApiKey!,
@@ -888,9 +910,74 @@ async function main() {
888
910
  // live probe 2026-07-31. Still no CallLedger — it's
889
911
  // anthropic-transport-only for now; cache metrics are visible in
890
912
  // llm-calls.jsonl via the logging wrapper.
913
+ // BEDROCK_BASE_URL routes bedrock-runtime calls through an inference
914
+ // gateway (gate.animalabs.ai/bedrock/<credSet>) — mirrors the
915
+ // ANTHROPIC_BASE_URL hook below. The gate reads the agent token from the
916
+ // SigV4 Credential (AWS_ACCESS_KEY_ID slot), discards the client
917
+ // signature, and re-signs with real AWS creds held on the gate box.
891
918
  const bedrockAdapter = provider === 'bedrock'
892
- ? new LoggingBedrockAdapter({}, llmLogPath)
919
+ ? new LoggingBedrockAdapter(
920
+ { baseURL: process.env.BEDROCK_BASE_URL || undefined },
921
+ llmLogPath,
922
+ )
923
+ : undefined;
924
+ // Mock: membrane's canned/echo adapter — the full host loop with zero
925
+ // provider spend and no credentials (none of the key checks above are
926
+ // gated on it). Echo is the default because it's the informative shape
927
+ // for interactive smoke runs; recipe agent.mock.echoMode=false switches
928
+ // to defaultResponse for deterministic scripted output. It rides the
929
+ // generic logging decorator so even mock calls leave llm-calls.jsonl
930
+ // receipts — the observability path is part of what a mock run exercises.
931
+ const mockAdapter = provider === 'mock'
932
+ ? new MockAdapter({
933
+ echoMode: recipe.agent.mock?.echoMode ?? true,
934
+ ...(recipe.agent.mock?.defaultResponse !== undefined
935
+ ? { defaultResponse: recipe.agent.mock.defaultResponse }
936
+ : {}),
937
+ })
893
938
  : undefined;
939
+ // -- x-gate-debt-chunks stamp (membrane dynamicHeaders, antra-tess/membrane#65)
940
+ // The gate records compression-debt per ledger row; debt only changes at
941
+ // calls, so the per-call series is its full-resolution history ("spoke ->
942
+ // chunk appeared -> chewed" vs "solver repacked -> queue spiked"). The value
943
+ // is the SAME reduction /healthz reports (cm getCompressionDebt). Late-bound
944
+ // through appRef because the framework outlives adapter construction and is
945
+ // replaced on session switch. Whenever it is unreadable — no framework yet,
946
+ // multi-agent process (whose debt would we even claim?), no strategy — the
947
+ // header is simply not sent: an unstamped call is honest, a guessed one lies.
948
+ let appRefForDebt: AppContext | null = null;
949
+ const pendingDebtChunks = (): number | null => {
950
+ try {
951
+ const agents = appRefForDebt?.framework.getAllAgents() ?? [];
952
+ if (agents.length !== 1) return null;
953
+ const strategy = (agents[0] as unknown as {
954
+ getContextManager?: () => { getStrategy?: () => { getCompressionDebt?: () => unknown } };
955
+ }).getContextManager?.()?.getStrategy?.();
956
+ const d = strategy?.getCompressionDebt?.() as { pendingChunks?: unknown } | undefined;
957
+ const n = d?.pendingChunks;
958
+ return typeof n === 'number' && Number.isFinite(n) && n >= 0 ? Math.round(n) : null;
959
+ } catch {
960
+ return null;
961
+ }
962
+ };
963
+
964
+ // Why the turn in progress fired (heartbeat / a channel message by whom /
965
+ // operator), read from the framework's active-turn trigger. Same guards as
966
+ // the debt getter: no framework or several agents
967
+ // -> null -> the origin trio is simply not sent.
968
+ const activeTurnTrigger = (): TurnTrigger | null => {
969
+ try {
970
+ const agents = appRefForDebt?.framework.getAllAgents() ?? [];
971
+ if (agents.length !== 1) return null;
972
+ const t = appRefForDebt?.framework.getActiveTurnTrigger(agents[0]!.name);
973
+ return t ? { reason: t.reason, source: t.source, channelId: t.channelId, counterparty: t.counterparty } : null;
974
+ } catch {
975
+ return null;
976
+ }
977
+ };
978
+
979
+ const gateTelemetryDynamicHeaders = gateTelemetryHeaders(process.env, pendingDebtChunks, activeTurnTrigger);
980
+
894
981
  const adapter = provider === 'openai-responses'
895
982
  ? new LoggingProviderAdapter(
896
983
  new OpenAIResponsesAPIAdapter({
@@ -905,7 +992,9 @@ async function main() {
905
992
  // `openrouterAdapter` instances stay un-wrapped for auth commands and
906
993
  // dispose() — only the membrane sees the wrapper.
907
994
  : bedrockAdapter
995
+ ?? (mockAdapter ? new LoggingProviderAdapter(mockAdapter, llmLogPath) : undefined)
908
996
  ?? (openrouterAdapter ? new LoggingProviderAdapter(openrouterAdapter, llmLogPath) : undefined)
997
+ ?? (openaiCompatibleAdapter ? new LoggingProviderAdapter(openaiCompatibleAdapter, llmLogPath) : undefined)
909
998
  ?? (codexAdapter ? new LoggingProviderAdapter(codexAdapter, llmLogPath) : undefined)
910
999
  ?? new LoggingAnthropicAdapter(
911
1000
  {
@@ -927,6 +1016,29 @@ async function main() {
927
1016
  : {}),
928
1017
  }),
929
1018
  baseURL: process.env.ANTHROPIC_BASE_URL || undefined,
1019
+ // Double-gated (see src/gate-telemetry.ts): GATE_TELEMETRY=1 AND a
1020
+ // configured base URL, so the stamp can only ever go to a
1021
+ // gateway the operator has declared — never to the vendor's
1022
+ // default endpoint (review finding on the first wiring).
1023
+ ...(gateTelemetryDynamicHeaders ? { dynamicHeaders: gateTelemetryDynamicHeaders } : {}),
1024
+ // Hold this agent's cached prefix warm across idle gaps. Only fires
1025
+ // when the entry is actually near expiry, so a busy agent costs
1026
+ // nothing; only the 1h-TTL primary lane is eligible (the module
1027
+ // skips anything else). Off with cacheKeepalive.enabled: false.
1028
+ cacheKeepalive: {
1029
+ enabled: recipe.agent.cacheKeepalive?.enabled !== false,
1030
+ ...(recipe.agent.cacheKeepalive?.maxIdleHours !== undefined
1031
+ ? { maxIdleMs: recipe.agent.cacheKeepalive.maxIdleHours * 60 * 60_000 }
1032
+ : {}),
1033
+ ...(recipe.agent.cacheKeepalive?.refreshAfterMinutes !== undefined
1034
+ ? { refreshAfterMs: recipe.agent.cacheKeepalive.refreshAfterMinutes * 60_000 }
1035
+ : {}),
1036
+ // Every event — routine refreshes included — goes to stderr, so
1037
+ // all of them land in service-stderr.log next to
1038
+ // [inference-refusal]. See cache-keepalive-log.ts for why this is
1039
+ // not severity-routed.
1040
+ onEvent: logKeepaliveEvent,
1041
+ },
930
1042
  },
931
1043
  llmLogPath,
932
1044
  () => settingsModule.getReasoning(),
@@ -996,6 +1108,7 @@ async function main() {
996
1108
  branchState: createBranchState(),
997
1109
  userMessageCount: 0,
998
1110
  codexAdapter,
1111
+ callLedger,
999
1112
 
1000
1113
  async switchSession(id: string) {
1001
1114
  handleExport(this);
@@ -1016,6 +1129,8 @@ async function main() {
1016
1129
  },
1017
1130
  };
1018
1131
 
1132
+ appRefForDebt = app;
1133
+
1019
1134
  // Off-path refusal dragnet → ops alerts (observability M3): refusals on
1020
1135
  // non-streamed calls (compression/summarizer drains, maintenance) never
1021
1136
  // reach the framework's own noteRefusal — the 2026-07-15 mythos cascade
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
8
8
  import { resolve, dirname } from 'node:path';
9
+ import { REFUSAL_REACTION_BASELINE } from '@animalabs/agent-framework';
9
10
 
10
11
  /** Default config file path, resolved from cwd. */
11
12
  export const DEFAULT_CONFIG_PATH = resolve(process.cwd(), 'mcpl-servers.json');
@@ -134,10 +135,57 @@ export function saveAgentOverlay(
134
135
  writeFileSync(overlayPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
135
136
  }
136
137
 
138
+ /**
139
+ * Capabilities an agent-deployed server is never granted by default: the
140
+ * consequential surfaces — context hooks (observation of and injection into
141
+ * the agent's own inference), server-initiated inference, and inference
142
+ * lifecycle. Bare parents on purpose: the config mask's subtree matching
143
+ * denies everything beneath them, present and future (afterInference, undo,
144
+ * state land under these the day the vocabulary grows them). A world/chat
145
+ * server needs none of this — channels + tools is the whole job. An operator
146
+ * who wants a self-deployed server to hold one of these moves the server
147
+ * into the recipe, where `enabledCapabilities` is theirs to write.
148
+ */
149
+ export const AGENT_DEPLOY_DENIED_CAPABILITIES: readonly string[] = [
150
+ 'contextHooks',
151
+ 'inferenceRequest',
152
+ 'inferenceLifecycle',
153
+ ];
154
+
155
+ /** The allow/deny list fields where an EMPTY array carries no intent (see
156
+ * resolveOverlayEntry — OpenAI strict function calling forces every schema
157
+ * property, so agent tool calls arrive with `[]` meaning "unspecified"). */
158
+ const OVERLAY_LIST_FIELDS = [
159
+ 'enabledFeatureSets',
160
+ 'disabledFeatureSets',
161
+ 'enabledTools',
162
+ 'disabledTools',
163
+ ] as const;
164
+
137
165
  /**
138
166
  * Resolve an overlay entry into a server config object (id + fields, relative
139
167
  * `./`/`../` args resolved against the overlay file's directory). Returns
140
168
  * null for tombstones and entries with neither command nor url.
169
+ *
170
+ * The overlay is the AGENT's file, so resolution is also where host policy
171
+ * for self-deployed servers lives (applied at boot AND at deploy — existing
172
+ * files heal without a re-deploy):
173
+ *
174
+ * - Empty allow/deny lists are treated as absent. OpenAI-style strict
175
+ * function calling forces every schema property, so GPT-family residents
176
+ * calling mcpl_deploy emit `[]` where they meant "unspecified" — and for
177
+ * the allowlists PRESENT-empty is deny-all under the §5.3 pin (Mica's
178
+ * silently eventless eidoverse, 2026-08-04). An agent that truly wants
179
+ * deny-all says `disabledTools: ["*"]` / `disabledFeatureSets: ["*"]`.
180
+ *
181
+ * - `enabledCapabilities` is dropped: the agent's file can narrow, never
182
+ * widen — a hand-written entry here could re-grant §13.4 deny-by-default
183
+ * paths.
184
+ *
185
+ * - `disabledCapabilities` always carries at least
186
+ * AGENT_DEPLOY_DENIED_CAPABILITIES (unioned with anything the entry
187
+ * already denies): self-deployed servers get channels + tools and
188
+ * nothing consequential by default.
141
189
  */
142
190
  export function resolveOverlayEntry(
143
191
  id: string,
@@ -148,9 +196,29 @@ export function resolveOverlayEntry(
148
196
  if (!entry.command && !entry.url) return null;
149
197
  const overlayDir = dirname(resolve(overlayPath));
150
198
  const { disabled: _d, ...fields } = entry;
199
+ const rec = fields as Record<string, unknown>;
200
+ for (const k of OVERLAY_LIST_FIELDS) {
201
+ if (Array.isArray(rec[k]) && (rec[k] as unknown[]).length === 0) delete rec[k];
202
+ }
203
+ delete rec.enabledCapabilities;
204
+ // A network server the agent deployed should come back when it bounces.
205
+ // reconnect defaulted to false, so an entry that never said `reconnect:
206
+ // true` was severed PERMANENTLY by any server restart — with no signal to
207
+ // anyone — until the agent's own next restart, which for a long-lived
208
+ // resident is days away (Mythos, eventless in eidoverse after the
209
+ // 2026-08-04 door deploy). Websocket entries now default to reconnect
210
+ // unless the entry explicitly says false. Stdio entries keep the old
211
+ // default: reconnect does not respawn a dead child anyway (mcpl_restart
212
+ // is that path), so `true` there would promise something it can't do.
213
+ if (entry.url && rec.reconnect === undefined) rec.reconnect = true;
214
+ const denied = new Set<string>([
215
+ ...AGENT_DEPLOY_DENIED_CAPABILITIES,
216
+ ...(Array.isArray(rec.disabledCapabilities) ? (rec.disabledCapabilities as unknown[]).map(String) : []),
217
+ ]);
151
218
  return {
152
219
  id,
153
- ...fields,
220
+ ...rec,
221
+ disabledCapabilities: [...denied].sort(),
154
222
  ...(entry.args
155
223
  ? {
156
224
  args: entry.args.map(arg =>
@@ -206,3 +274,33 @@ export function saveMcplServers(configPath: string, servers: Record<string, Serv
206
274
  const data: McplServersFile = { mcplServers: servers };
207
275
  writeFileSync(configPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
208
276
  }
277
+
278
+ /**
279
+ * Compose the environment for a stdio MCPL child.
280
+ *
281
+ * Two host-owned values ride along with whatever the server entry declares:
282
+ *
283
+ * - `DISCORD_SUPPRESSED_REACTIONS_BASELINE` — the framework's exported
284
+ * refusal-annotation set (REFUSAL_REACTION_BASELINE, comma-joined), so a
285
+ * never-configured Discord adapter defaults to suppressing exactly the
286
+ * markers this host's framework stamps. Placed BEFORE the spread: an
287
+ * operator who sets the var on the server entry supersedes the house
288
+ * baseline — the host injects a default, never overrides a decision. The
289
+ * adapter's own precedence (file key incl. [] → legacy operator env →
290
+ * baseline) then decides what is actually enforced; house markers are
291
+ * Host semantics, and a standalone adapter without this composition stays
292
+ * honestly unprotected.
293
+ * - `AGENT_TIMEZONE` — after the spread, deliberately: the agent-facing
294
+ * wall clock is resolved per-recipe by the host and is not a per-server
295
+ * operator knob.
296
+ */
297
+ export function composeMcplChildEnv(
298
+ serverEnv: Record<string, string> | undefined,
299
+ timeZone: string,
300
+ ): Record<string, string> {
301
+ return {
302
+ DISCORD_SUPPRESSED_REACTIONS_BASELINE: REFUSAL_REACTION_BASELINE.join(','),
303
+ ...(serverEnv ?? {}),
304
+ AGENT_TIMEZONE: timeZone,
305
+ };
306
+ }
@@ -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.