@animalabs/connectome-host 0.7.4 → 0.8.1

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 (67) 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 +320 -0
  7. package/CONTRIBUTING.md +47 -19
  8. package/README.md +27 -0
  9. package/bun.lock +26 -32
  10. package/changelog.d/README.md +28 -0
  11. package/package.json +6 -6
  12. package/recipes/SETUP.md +11 -5
  13. package/recipes/TRIUMVIRATE-SETUP.md +68 -14
  14. package/recipes/knowledge-miner.json +0 -30
  15. package/recipes/mock-test.json +19 -0
  16. package/recipes/triumvirate.json +6 -1
  17. package/scripts/release-changelog.ts +210 -21
  18. package/src/cache-keepalive-log.ts +41 -0
  19. package/src/commands.ts +221 -32
  20. package/src/framework-agent-config.ts +3 -0
  21. package/src/framework-strategy.ts +42 -0
  22. package/src/gate-telemetry.ts +134 -0
  23. package/src/headless.ts +10 -0
  24. package/src/index.ts +194 -55
  25. package/src/mcpl-config.ts +99 -1
  26. package/src/modules/identity-module.ts +310 -2
  27. package/src/modules/instructions-module.ts +265 -0
  28. package/src/modules/mcpl-admin-module.ts +58 -11
  29. package/src/modules/subagent-module.ts +18 -0
  30. package/src/modules/web-ui-module.ts +32 -4
  31. package/src/recipe.ts +821 -25
  32. package/src/web/panel-data.ts +44 -1
  33. package/src/workspace-mounts.ts +73 -0
  34. package/test/audit-module-optins.test.ts +10 -3
  35. package/test/cache-keepalive-log.test.ts +83 -0
  36. package/test/commands-qa-family.test.ts +239 -0
  37. package/test/conversations-recipe.test.ts +142 -0
  38. package/test/count-tokens-model.test.ts +31 -0
  39. package/test/framework-fkm-composition.test.ts +35 -3
  40. package/test/framework-strategy-defaults.test.ts +60 -0
  41. package/test/gate-telemetry-adapter.test.ts +84 -0
  42. package/test/gate-telemetry.test.ts +124 -0
  43. package/test/identity-and-surfaces.test.ts +212 -1
  44. package/test/instructions-module.test.ts +258 -0
  45. package/test/mcpl-admin-module.test.ts +41 -0
  46. package/test/mcpl-agent-overlay.test.ts +51 -3
  47. package/test/mcpl-child-env.test.ts +64 -0
  48. package/test/nudge-command.test.ts +47 -0
  49. package/test/recipe-cache-keepalive.test.ts +59 -0
  50. package/test/recipe-compression-fallback.test.ts +19 -0
  51. package/test/recipe-hybrid-prose-routing.test.ts +12 -0
  52. package/test/recipe-instructions.test.ts +176 -0
  53. package/test/recipe-kv-unified.test.ts +87 -0
  54. package/test/recipe-mcp-source.test.ts +54 -0
  55. package/test/recipe-openai-compatible.test.ts +54 -0
  56. package/test/recipe-path-resolution.test.ts +19 -8
  57. package/test/recipe-provider.test.ts +14 -0
  58. package/test/recipe-save-unresolved.test.ts +244 -0
  59. package/test/recipe-source-only.test.ts +38 -0
  60. package/test/release-changelog.test.ts +202 -0
  61. package/test/subagent-prose-routing.test.ts +109 -0
  62. package/test/subconscious-recipe.test.ts +86 -0
  63. package/test/tool-wrapper-prose-guard-recipe.test.ts +37 -0
  64. package/test/web-ui-module.test.ts +41 -0
  65. package/test/workspace-mounts.test.ts +68 -0
  66. package/web/src/App.tsx +10 -0
  67. package/web/src/Health.tsx +61 -1
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,18 +19,21 @@ 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, stampedTrigger, 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';
@@ -38,7 +41,6 @@ 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
43
  import { buildRetrievalModuleConfig } from './retrieval-config.js';
41
- import type { RecipeWorkspaceMount } from './recipe.js';
42
44
  import { TuiModule } from './modules/tui-module.js';
43
45
  import { TimeModule } from './modules/time-module.js';
44
46
  import { FleetModule, type FleetModuleConfig } from './modules/fleet-module.js';
@@ -50,14 +52,15 @@ import { ObserversModule } from './modules/observers-module.js';
50
52
  import { IdentityModule } from './modules/identity-module.js';
51
53
  import { McplAdminModule } from './modules/mcpl-admin-module.js';
52
54
  import { TtsRelayModule } from './modules/tts-relay-module.js';
53
- 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';
54
57
  import { SessionManager } from './session-manager.js';
55
58
  import { resolveAgentName } from './agent-name.js';
56
59
  import { generateSessionName } from './synesthete.js';
57
60
  import {
58
61
  type Recipe,
59
62
  DEFAULT_RECIPE,
60
- loadRecipe,
63
+ loadRecipeDetailed,
61
64
  saveRecipe,
62
65
  loadSavedRecipe,
63
66
  clearSavedRecipe,
@@ -65,7 +68,9 @@ import {
65
68
  } from './recipe.js';
66
69
  import { createBranchState, resetBranchState, handleExport, type BranchState } from './commands.js';
67
70
  import { buildFrameworkAgentConfig, membraneCachingOverride } from './framework-agent-config.js';
68
- 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';
69
74
  import { loadExtensions } from './extensions.js';
70
75
 
71
76
  export type { AppContext };
@@ -80,6 +85,10 @@ const config = {
80
85
  authToken: process.env.ANTHROPIC_AUTH_TOKEN,
81
86
  openaiApiKey: process.env.OPENAI_API_KEY,
82
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,
83
92
  codexBinary: process.env.CODEX_BINARY,
84
93
  model: process.env.MODEL,
85
94
  dataDir: process.env.DATA_DIR || './data',
@@ -128,8 +137,10 @@ async function resolveRecipe(): Promise<Recipe> {
128
137
 
129
138
  if (source) {
130
139
  try {
131
- const recipe = await loadRecipe(source);
132
- 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);
133
144
  console.log(`Loaded recipe: ${recipe.name}${recipe.description ? ` — ${recipe.description}` : ''}`);
134
145
  return recipe;
135
146
  } catch (err) {
@@ -138,11 +149,21 @@ async function resolveRecipe(): Promise<Recipe> {
138
149
  }
139
150
  }
140
151
 
141
- // Try saved recipe
142
- const saved = loadSavedRecipe(config.dataDir);
143
- if (saved) {
144
- console.log(`Resuming recipe: ${saved.name}`);
145
- 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);
146
167
  }
147
168
 
148
169
  return DEFAULT_RECIPE;
@@ -260,48 +281,30 @@ async function createFramework(
260
281
  // Note: workspace: false disables ALL filesystem access (both read and write).
261
282
  // Previously LocalFilesModule was always-on; this is an intentional change —
262
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).
263
287
  let workspaceModule: WorkspaceModule | null = null;
264
- if (modules.workspace !== false) {
265
- let mounts: MountConfig[];
266
- if (typeof modules.workspace === 'object' && modules.workspace.mounts) {
267
- // Only pass fields the recipe explicitly provides; let WorkspaceModule default the rest.
268
- // We override watch to 'never' since FKM doesn't need chokidar filesystem watchers.
269
- mounts = modules.workspace.mounts.map((m: RecipeWorkspaceMount) => {
270
- const mount: MountConfig = {
271
- name: m.name,
272
- path: resolve(m.path),
273
- mode: m.mode ?? 'read-write',
274
- watch: m.watch ?? 'never', // FKM: no chokidar watchers by default
275
- };
276
- if (m.ignore) mount.ignore = m.ignore;
277
- if (m.wakeOnChange !== undefined) mount.wakeOnChange = m.wakeOnChange;
278
- if (m.autoMaterialize !== undefined) mount.autoMaterialize = m.autoMaterialize;
279
- return mount;
280
- });
281
- } else {
282
- // Default: read-only input mount + read-write products mount
283
- mounts = [
284
- { name: 'input', path: resolve('./input'), mode: 'read-only', watch: 'never' },
285
- { name: 'products', path: resolve('./output'), mode: 'read-write', watch: 'never' },
286
- ];
287
- }
288
-
289
- // Config mount: version-controls gate.json (and future config files) via Chronicle.
290
- // Opt-in via recipe: workspace.configMount = true
291
- const wantConfigMount = typeof modules.workspace === 'object' && modules.workspace.configMount;
292
- if (wantConfigMount) {
293
- mounts.push({
294
- name: '_config',
295
- path: resolve(join(storePath, 'config')),
296
- mode: 'read-write',
297
- watch: 'always',
298
- });
299
- }
300
-
301
- workspaceModule = new WorkspaceModule({ mounts });
288
+ const workspaceMounts = buildWorkspaceMounts(modules.workspace, storePath);
289
+ if (workspaceMounts) {
290
+ workspaceModule = new WorkspaceModule({ mounts: workspaceMounts });
302
291
  moduleInstances.push(workspaceModule);
303
292
  }
304
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
+
305
308
  // Activity (typing indicators) — opt-in per recipe
306
309
  let activityModule: ActivityModule | null = null;
307
310
  if (modules.activity !== undefined && modules.activity !== false) {
@@ -461,9 +464,11 @@ async function createFramework(
461
464
  const finalServers = applyAgentOverlay(allServers, DEFAULT_AGENT_OVERLAY_PATH).map((server) => {
462
465
  const withEnv: { id: string; command?: string; url?: string; [k: string]: unknown } = {
463
466
  ...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
+ // 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),
467
472
  };
468
473
  // `access` is a declarative name (recipe/file/overlay); the credential
469
474
  // provider it implies is attached HERE, at load time — fresh credential
@@ -489,6 +494,10 @@ async function createFramework(
489
494
  const strategy = buildFrameworkStrategy(recipe, model, timeZone, extensionRegistry);
490
495
  const agentConfig = buildFrameworkAgentConfig(recipe, agentName, model, strategy);
491
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
+
492
501
  // -- Create framework --
493
502
  const framework = await AgentFramework.create({
494
503
  storePath,
@@ -500,6 +509,10 @@ agents: [agentConfig],
500
509
  timeZone,
501
510
  // Client-side programmatic tool calling (code_execution) — recipe opt-in.
502
511
  ...(recipe.codeExecution ? { codeExecution: recipe.codeExecution } : {}),
512
+ ...(conversations ? { conversations } : {}),
513
+ // Tune-out's subconscious resident (agent-framework#77) — recipe opt-in,
514
+ // passed through verbatim; the framework owns the defaults.
515
+ ...(recipe.subconscious ? { subconscious: recipe.subconscious } : {}),
503
516
  });
504
517
 
505
518
  // Wire post-creation hooks
@@ -872,6 +885,16 @@ async function main() {
872
885
  fastMode: recipe.agent.codex?.fastMode ?? false,
873
886
  })
874
887
  : undefined;
888
+ // Generic OpenAI-compatible chat-completions endpoint (Ollama, vLLM, Together,
889
+ // Groq, NanoGPT, ...). The recipe carries the endpoint (agent.baseUrl,
890
+ // validated at load); the key is optional because local servers have none.
891
+ const openaiCompatibleAdapter = provider === 'openai-compatible'
892
+ ? new OpenAICompatibleAdapter({
893
+ baseURL: recipe.agent.baseUrl!,
894
+ apiKey: config.openaiCompatibleApiKey || undefined,
895
+ providerName: 'openai-compatible',
896
+ })
897
+ : undefined;
875
898
  const openrouterAdapter = provider === 'openrouter'
876
899
  ? new OpenRouterAdapter({
877
900
  apiKey: config.openrouterApiKey!,
@@ -890,9 +913,98 @@ async function main() {
890
913
  // live probe 2026-07-31. Still no CallLedger — it's
891
914
  // anthropic-transport-only for now; cache metrics are visible in
892
915
  // llm-calls.jsonl via the logging wrapper.
916
+ // BEDROCK_BASE_URL routes bedrock-runtime calls through an inference
917
+ // gateway (gate.animalabs.ai/bedrock/<credSet>) — mirrors the
918
+ // ANTHROPIC_BASE_URL hook below. The gate reads the agent token from the
919
+ // SigV4 Credential (AWS_ACCESS_KEY_ID slot), discards the client
920
+ // signature, and re-signs with real AWS creds held on the gate box.
893
921
  const bedrockAdapter = provider === 'bedrock'
894
- ? new LoggingBedrockAdapter({}, llmLogPath)
922
+ ? new LoggingBedrockAdapter(
923
+ { baseURL: process.env.BEDROCK_BASE_URL || undefined },
924
+ llmLogPath,
925
+ )
926
+ : undefined;
927
+ // Mock: membrane's canned/echo adapter — the full host loop with zero
928
+ // provider spend and no credentials (none of the key checks above are
929
+ // gated on it). Echo is the default because it's the informative shape
930
+ // for interactive smoke runs; recipe agent.mock.echoMode=false switches
931
+ // to defaultResponse for deterministic scripted output. It rides the
932
+ // generic logging decorator so even mock calls leave llm-calls.jsonl
933
+ // receipts — the observability path is part of what a mock run exercises.
934
+ const mockAdapter = provider === 'mock'
935
+ ? new MockAdapter({
936
+ echoMode: recipe.agent.mock?.echoMode ?? true,
937
+ ...(recipe.agent.mock?.defaultResponse !== undefined
938
+ ? { defaultResponse: recipe.agent.mock.defaultResponse }
939
+ : {}),
940
+ })
895
941
  : undefined;
942
+ // -- x-gate-debt-chunks stamp (membrane dynamicHeaders, antra-tess/membrane#65)
943
+ // The gate records compression-debt per ledger row; debt only changes at
944
+ // calls, so the per-call series is its full-resolution history ("spoke ->
945
+ // chunk appeared -> chewed" vs "solver repacked -> queue spiked"). The value
946
+ // is the SAME reduction /healthz reports (cm getCompressionDebt). Late-bound
947
+ // through appRef because the framework outlives adapter construction and is
948
+ // replaced on session switch. Whenever it is unreadable — no framework yet,
949
+ // multi-agent process (whose debt would we even claim?), no strategy — the
950
+ // header is simply not sent: an unstamped call is honest, a guessed one lies.
951
+ let appRefForDebt: AppContext | null = null;
952
+ // The resident whose turn/debt we stamp. TODO(agent-framework ≥0.14): use a
953
+ // public getPrimaryAgentName() accessor instead of the private field read.
954
+ const primaryAgent = (): { name: string; agent: unknown } | null => {
955
+ const fw = appRefForDebt?.framework;
956
+ const agents = fw?.getAllAgents() ?? [];
957
+ const name = (fw as unknown as { primaryAgentName?: string } | undefined)?.primaryAgentName
958
+ ?? (agents.length === 1 ? agents[0]!.name : undefined);
959
+ if (!name) return null;
960
+ const agent = agents.find((a) => a.name === name);
961
+ return agent ? { name, agent } : null;
962
+ };
963
+ const pendingDebtChunks = (): number | null => {
964
+ try {
965
+ const p = primaryAgent();
966
+ if (!p) return null;
967
+ const strategy = (p.agent as unknown as {
968
+ getContextManager?: () => { getStrategy?: () => { getCompressionDebt?: () => unknown } };
969
+ }).getContextManager?.()?.getStrategy?.();
970
+ const d = strategy?.getCompressionDebt?.() as { pendingChunks?: unknown } | undefined;
971
+ const n = d?.pendingChunks;
972
+ return typeof n === 'number' && Number.isFinite(n) && n >= 0 ? Math.round(n) : null;
973
+ } catch {
974
+ return null;
975
+ }
976
+ };
977
+
978
+ // Why the turn in progress fired (heartbeat / a channel message by whom /
979
+ // operator), read from the framework's active-turn trigger. Same guards as
980
+ // the debt getter: no framework or several agents
981
+ // -> null -> the origin trio is simply not sent.
982
+ const activeTurnTrigger = (): TurnTrigger | null => {
983
+ try {
984
+ const fw = appRefForDebt?.framework;
985
+ if (!fw) return null;
986
+ const agents = fw.getAllAgents();
987
+ // ONE adapter serves every agent in this process, so this hook cannot
988
+ // tell whose request it is decorating: stamp the primary's trigger only
989
+ // while no other agent (subconscious, fork, ephemeral) has a turn in
990
+ // flight — see stampedTrigger().
991
+ const t = stampedTrigger({
992
+ agents: agents.map((a) => a.name),
993
+ primary: primaryAgent()?.name,
994
+ triggerOf: (name) => {
995
+ const r = fw.getActiveTurnTrigger(name) as
996
+ (ReturnType<typeof fw.getActiveTurnTrigger> & { wakeChannelId?: string }) | undefined;
997
+ return r ? { reason: r.reason, source: r.source, channelId: r.channelId, wakeChannelId: r.wakeChannelId, counterparty: r.counterparty } : null;
998
+ },
999
+ });
1000
+ return t;
1001
+ } catch {
1002
+ return null;
1003
+ }
1004
+ };
1005
+
1006
+ const gateTelemetryDynamicHeaders = gateTelemetryHeaders(process.env, pendingDebtChunks, activeTurnTrigger);
1007
+
896
1008
  const adapter = provider === 'openai-responses'
897
1009
  ? new LoggingProviderAdapter(
898
1010
  new OpenAIResponsesAPIAdapter({
@@ -907,7 +1019,9 @@ async function main() {
907
1019
  // `openrouterAdapter` instances stay un-wrapped for auth commands and
908
1020
  // dispose() — only the membrane sees the wrapper.
909
1021
  : bedrockAdapter
1022
+ ?? (mockAdapter ? new LoggingProviderAdapter(mockAdapter, llmLogPath) : undefined)
910
1023
  ?? (openrouterAdapter ? new LoggingProviderAdapter(openrouterAdapter, llmLogPath) : undefined)
1024
+ ?? (openaiCompatibleAdapter ? new LoggingProviderAdapter(openaiCompatibleAdapter, llmLogPath) : undefined)
911
1025
  ?? (codexAdapter ? new LoggingProviderAdapter(codexAdapter, llmLogPath) : undefined)
912
1026
  ?? new LoggingAnthropicAdapter(
913
1027
  {
@@ -929,6 +1043,29 @@ async function main() {
929
1043
  : {}),
930
1044
  }),
931
1045
  baseURL: process.env.ANTHROPIC_BASE_URL || undefined,
1046
+ // Double-gated (see src/gate-telemetry.ts): GATE_TELEMETRY=1 AND a
1047
+ // configured base URL, so the stamp can only ever go to a
1048
+ // gateway the operator has declared — never to the vendor's
1049
+ // default endpoint (review finding on the first wiring).
1050
+ ...(gateTelemetryDynamicHeaders ? { dynamicHeaders: gateTelemetryDynamicHeaders } : {}),
1051
+ // Hold this agent's cached prefix warm across idle gaps. Only fires
1052
+ // when the entry is actually near expiry, so a busy agent costs
1053
+ // nothing; only the 1h-TTL primary lane is eligible (the module
1054
+ // skips anything else). Off with cacheKeepalive.enabled: false.
1055
+ cacheKeepalive: {
1056
+ enabled: recipe.agent.cacheKeepalive?.enabled !== false,
1057
+ ...(recipe.agent.cacheKeepalive?.maxIdleHours !== undefined
1058
+ ? { maxIdleMs: recipe.agent.cacheKeepalive.maxIdleHours * 60 * 60_000 }
1059
+ : {}),
1060
+ ...(recipe.agent.cacheKeepalive?.refreshAfterMinutes !== undefined
1061
+ ? { refreshAfterMs: recipe.agent.cacheKeepalive.refreshAfterMinutes * 60_000 }
1062
+ : {}),
1063
+ // Every event — routine refreshes included — goes to stderr, so
1064
+ // all of them land in service-stderr.log next to
1065
+ // [inference-refusal]. See cache-keepalive-log.ts for why this is
1066
+ // not severity-routed.
1067
+ onEvent: logKeepaliveEvent,
1068
+ },
932
1069
  },
933
1070
  llmLogPath,
934
1071
  () => settingsModule.getReasoning(),
@@ -1019,6 +1156,8 @@ async function main() {
1019
1156
  },
1020
1157
  };
1021
1158
 
1159
+ appRefForDebt = app;
1160
+
1022
1161
  // Off-path refusal dragnet → ops alerts (observability M3): refusals on
1023
1162
  // non-streamed calls (compression/summarizer drains, maintenance) never
1024
1163
  // 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
+ }