@animalabs/connectome-host 0.3.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 (123) hide show
  1. package/.env.example +20 -0
  2. package/.github/workflows/publish.yml +65 -0
  3. package/ARCHITECTURE.md +355 -0
  4. package/CHANGELOG.md +30 -0
  5. package/HEADLESS-FLEET-PLAN.md +330 -0
  6. package/README.md +189 -0
  7. package/UNIFIED-TREE-PLAN.md +242 -0
  8. package/bun.lock +288 -0
  9. package/docs/AGENT-MEMORY-GUIDE.md +214 -0
  10. package/docs/AGENT-ONBOARDING.md +541 -0
  11. package/docs/ATTENTION-AND-GATING.md +120 -0
  12. package/docs/DEPLOYMENTS.md +130 -0
  13. package/docs/DEV-ENVIRONMENT.md +215 -0
  14. package/docs/LIBRARY-PIPELINE.md +286 -0
  15. package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
  16. package/docs/claudeai-evacuation.md +228 -0
  17. package/docs/debug-context-api.md +208 -0
  18. package/docs/webui-deployment.md +219 -0
  19. package/package.json +33 -0
  20. package/recipes/SETUP.md +308 -0
  21. package/recipes/TRIUMVIRATE-SETUP.md +467 -0
  22. package/recipes/claude-export-revive.json +19 -0
  23. package/recipes/clerk.json +157 -0
  24. package/recipes/knowledge-miner.json +174 -0
  25. package/recipes/knowledge-reviewer.json +76 -0
  26. package/recipes/mcpl-editor-test.json +38 -0
  27. package/recipes/prompts/transplant-addendum.md +17 -0
  28. package/recipes/triumvirate.json +63 -0
  29. package/recipes/webui-fleet-test.json +27 -0
  30. package/recipes/webui-test.json +16 -0
  31. package/recipes/zulip-miner.json +87 -0
  32. package/scripts/connectome-doctor +373 -0
  33. package/scripts/evacuator.ts +956 -0
  34. package/scripts/import-claudeai-export.ts +747 -0
  35. package/scripts/lib/line-reader.ts +53 -0
  36. package/scripts/test-historical-thinking.ts +148 -0
  37. package/scripts/warmup-session.ts +336 -0
  38. package/src/agent-name.ts +58 -0
  39. package/src/commands.ts +1074 -0
  40. package/src/headless.ts +528 -0
  41. package/src/index.ts +867 -0
  42. package/src/logging-adapter.ts +208 -0
  43. package/src/mcpl-config.ts +199 -0
  44. package/src/modules/activity-module.ts +270 -0
  45. package/src/modules/channel-mode-module.ts +260 -0
  46. package/src/modules/fleet-module.ts +1705 -0
  47. package/src/modules/fleet-types.ts +225 -0
  48. package/src/modules/lessons-module.ts +465 -0
  49. package/src/modules/mcpl-admin-module.ts +345 -0
  50. package/src/modules/retrieval-module.ts +327 -0
  51. package/src/modules/settings-module.ts +143 -0
  52. package/src/modules/subagent-module.ts +2074 -0
  53. package/src/modules/subscription-gc-module.ts +322 -0
  54. package/src/modules/time-module.ts +85 -0
  55. package/src/modules/tui-module.ts +76 -0
  56. package/src/modules/web-ui-curve-page.ts +249 -0
  57. package/src/modules/web-ui-module.ts +2595 -0
  58. package/src/recipe.ts +1003 -0
  59. package/src/session-manager.ts +278 -0
  60. package/src/state/agent-tree-reducer.ts +431 -0
  61. package/src/state/fleet-tree-aggregator.ts +195 -0
  62. package/src/strategies/frontdesk-strategy.ts +364 -0
  63. package/src/synesthete.ts +68 -0
  64. package/src/tui.ts +2200 -0
  65. package/src/types/bun-ffi.d.ts +6 -0
  66. package/src/web/protocol.ts +648 -0
  67. package/test/agent-name.test.ts +62 -0
  68. package/test/agent-tree-fleet-launch.test.ts +64 -0
  69. package/test/agent-tree-reducer-parity.test.ts +194 -0
  70. package/test/agent-tree-reducer.test.ts +443 -0
  71. package/test/agent-tree-rollup.test.ts +109 -0
  72. package/test/autobio-progress-snapshot.test.ts +40 -0
  73. package/test/claudeai-export-importer.test.ts +386 -0
  74. package/test/evacuator.test.ts +332 -0
  75. package/test/fleet-commands.test.ts +244 -0
  76. package/test/fleet-no-subfleets.test.ts +147 -0
  77. package/test/fleet-orchestration.test.ts +353 -0
  78. package/test/fleet-recipe.test.ts +124 -0
  79. package/test/fleet-route.test.ts +44 -0
  80. package/test/fleet-smoke.test.ts +479 -0
  81. package/test/fleet-subscribe-union-e2e.test.ts +123 -0
  82. package/test/fleet-subscribe-union.test.ts +85 -0
  83. package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
  84. package/test/fleet-tree-aggregator.test.ts +215 -0
  85. package/test/frontdesk-strategy.test.ts +326 -0
  86. package/test/headless-describe.test.ts +182 -0
  87. package/test/headless-smoke.test.ts +190 -0
  88. package/test/logging-adapter.test.ts +87 -0
  89. package/test/mcpl-admin-module.test.ts +213 -0
  90. package/test/mcpl-agent-overlay.test.ts +126 -0
  91. package/test/mock-headless-child.ts +133 -0
  92. package/test/recipe-cache-ttl.test.ts +37 -0
  93. package/test/recipe-env.test.ts +157 -0
  94. package/test/recipe-path-resolution.test.ts +170 -0
  95. package/test/subagent-async-timeout.test.ts +381 -0
  96. package/test/subagent-fork-materialise.test.ts +241 -0
  97. package/test/subagent-peek-scoping.test.ts +180 -0
  98. package/test/subagent-peek-zombie.test.ts +148 -0
  99. package/test/subagent-reaper-in-flight.test.ts +229 -0
  100. package/test/subscription-gc-module.test.ts +136 -0
  101. package/test/warmup-handoff.test.ts +150 -0
  102. package/test/web-ui-bind.test.ts +51 -0
  103. package/test/web-ui-module.test.ts +246 -0
  104. package/test/web-ui-protocol.test.ts +0 -0
  105. package/tsconfig.json +14 -0
  106. package/web/bun.lock +357 -0
  107. package/web/index.html +12 -0
  108. package/web/package.json +24 -0
  109. package/web/src/App.tsx +1931 -0
  110. package/web/src/Context.tsx +158 -0
  111. package/web/src/ContextDocument.tsx +150 -0
  112. package/web/src/Files.tsx +271 -0
  113. package/web/src/Lessons.tsx +164 -0
  114. package/web/src/Mcpl.tsx +310 -0
  115. package/web/src/Stream.tsx +119 -0
  116. package/web/src/TreeSidebar.tsx +283 -0
  117. package/web/src/Usage.tsx +182 -0
  118. package/web/src/main.tsx +7 -0
  119. package/web/src/styles.css +26 -0
  120. package/web/src/tree.ts +268 -0
  121. package/web/src/wire.ts +120 -0
  122. package/web/tsconfig.json +21 -0
  123. package/web/vite.config.ts +32 -0
package/src/recipe.ts ADDED
@@ -0,0 +1,1003 @@
1
+ /**
2
+ * Recipe system — config-driven agent bootstrapping.
3
+ *
4
+ * A recipe defines everything domain-specific about an agent session:
5
+ * system prompt, MCP servers, which modules to enable, and naming hints.
6
+ *
7
+ * Recipes can be loaded from:
8
+ * - HTTP(S) URLs
9
+ * - Local file paths
10
+ * - Saved state from a previous run (data/.recipe.json)
11
+ * - Built-in default (generic assistant)
12
+ */
13
+
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
15
+ import { dirname, isAbsolute, resolve } from 'node:path';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Types
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export interface RecipeStrategy {
22
+ type: 'autobiographical' | 'passthrough' | 'frontdesk';
23
+ // Core window/compression sizing
24
+ headWindowTokens?: number;
25
+ recentWindowTokens?: number;
26
+ compressionModel?: string;
27
+ maxMessageTokens?: number;
28
+ overBudgetGraceRatio?: number;
29
+ // Compression/merge tuning passed through to the underlying
30
+ // autobiographical strategy (and frontdesk, which extends it).
31
+ // Declared here so a typo in a recipe (e.g. `l1BudgetTokes`) fails
32
+ // at recipe validation rather than silently defaulting at strategy
33
+ // construction. Keep in sync with `AutobiographicalConfig` in
34
+ // @animalabs/context-manager; this interface is the source of truth
35
+ // for what the recipe loader accepts under `agent.strategy`.
36
+ enforceBudget?: boolean;
37
+ maxSpeculativeL1s?: number;
38
+ positionedRecallPairs?: boolean;
39
+ recallHeaderTemplate?: string;
40
+ targetChunkTokens?: number;
41
+ mergeThreshold?: number;
42
+ summaryTargetTokens?: number;
43
+ l1BudgetTokens?: number;
44
+ l2BudgetTokens?: number;
45
+ l3BudgetTokens?: number;
46
+ toolResultMaxLastN?: number;
47
+ toolUseInputMaxTokens?: number;
48
+ // Adaptive resolution (document-based gradual compression). For the
49
+ // 'autobiographical' strategy type this defaults ON (see index.ts); set
50
+ // `adaptiveResolution: false` to opt back to the count-based hierarchical
51
+ // renderer. The remaining knobs only apply when adaptiveResolution is true.
52
+ adaptiveResolution?: boolean;
53
+ /** kv-stable: trust region P on per-turn perturbation (tokens re-read).
54
+ * See context-manager adaptive-resolution-design.md §13. */
55
+ kvStableReachTokens?: number;
56
+ /** kv-stable: quality-gap override threshold (§13.4). Default 0.35. */
57
+ kvStableQualityGapRatio?: number;
58
+ compressionSlackRatio?: number;
59
+ foldingStrategy?: 'flat-profile' | 'oldest-first' | 'kv-stable';
60
+ speculativeProduction?: boolean;
61
+ /** L1 production holdback: keep the newest N closed chunks out of the
62
+ * speculative compression queue (default 1); demand still overrides. */
63
+ l1HoldbackChunks?: number;
64
+ // Self-voice / compression framing. When the agent's name differs from
65
+ // 'Claude' (the strategy default for summaryParticipant), these MUST be set
66
+ // (especially summaryParticipant: <agent.name>) — otherwise self-recollections
67
+ // are stored under a stranger's participant and surface in the compiled prompt
68
+ // as another voice speaking in first person about the agent.
69
+ summaryParticipant?: string;
70
+ summarySystemPrompt?: string;
71
+ summaryUserPrompt?: string;
72
+ summaryContextLabel?: string;
73
+ }
74
+
75
+ export interface RecipeAgent {
76
+ name?: string;
77
+ model?: string;
78
+ systemPrompt: string;
79
+ maxTokens?: number;
80
+ /**
81
+ * Per-agent stream token budget — the accumulated-input ceiling at which the
82
+ * framework breaks the stream and lets the strategy compress. Mirrors the
83
+ * runtime `/budget` command. When unset, the framework default (150k) applies.
84
+ */
85
+ maxStreamTokens?: number;
86
+ /** Per-agent context compile budget (input tokens). When unset, the
87
+ * ContextManager default (100k) applies. Raise for large-context models. */
88
+ contextBudgetTokens?: number;
89
+ /** Prompt-cache TTL ('5m' | '1h') forwarded to the provider. Set '1h' for
90
+ * agents whose reply cadence is slower than 5 minutes — avoids re-writing
91
+ * the full context to cache after every gap. Unset: provider default (5m). */
92
+ cacheTtl?: '5m' | '1h';
93
+ strategy?: RecipeStrategy;
94
+ /**
95
+ * Native extended thinking. When `enabled: true`, the agent's API requests
96
+ * include Anthropic's thinking config and responses carry signed `thinking`
97
+ * blocks. Required for matching the cognitive mode of claude.ai web Sonnet.
98
+ */
99
+ thinking?: {
100
+ enabled: boolean;
101
+ budgetTokens?: number;
102
+ };
103
+ /**
104
+ * Content-refusal handling. When `autoRewind` is on, a `stop_reason: refusal`
105
+ * turn triggers an automatic rewind of the triggering turn + retry (keeping
106
+ * the agent on its own model). See agent-framework AgentConfig.refusalHandling.
107
+ */
108
+ refusalHandling?: {
109
+ autoRewind?: boolean;
110
+ maxRewinds?: number;
111
+ announceHumanTurns?: boolean;
112
+ };
113
+ }
114
+
115
+ export interface RecipeMcpServer {
116
+ /** Command to spawn (stdio transport). Mutually exclusive with url. */
117
+ command?: string;
118
+ args?: string[];
119
+ env?: Record<string, string>;
120
+ /** WebSocket URL (WebSocket transport). Mutually exclusive with command. */
121
+ url?: string;
122
+ transport?: 'stdio' | 'websocket';
123
+ /** Bearer token for WebSocket auth (appended as ?token= query param). */
124
+ token?: string;
125
+ toolPrefix?: string;
126
+ enabledFeatureSets?: string[];
127
+ disabledFeatureSets?: string[];
128
+ /**
129
+ * Tool allow-list (bare tool names as the server exports them, no toolPrefix).
130
+ * `*` is a substring wildcard (`read_*`, `*_file`, `*`).
131
+ * If set, only matching tools are exposed to the model AND callable at dispatch.
132
+ */
133
+ enabledTools?: string[];
134
+ /**
135
+ * Tool deny-list (same syntax as enabledTools). Wins over enabledTools on overlap.
136
+ * Denied tools are hidden from the model and rejected at dispatch — both, so a
137
+ * model that imitates a prior call from message history can't sneak the call through.
138
+ */
139
+ disabledTools?: string[];
140
+ reconnect?: boolean;
141
+ /** Base reconnect interval; the framework doubles it per consecutive
142
+ * failure (with jitter) up to reconnectMaxIntervalMs. */
143
+ reconnectIntervalMs?: number;
144
+ /** Ceiling for the exponential reconnect backoff. Default: 5 minutes. */
145
+ reconnectMaxIntervalMs?: number;
146
+ /**
147
+ * Channel auto-open policy. 'auto' (default) opens everything the server
148
+ * registers; 'manual' opens nothing (agent calls channel_open as needed);
149
+ * a string[] is an allow-list of channel ids.
150
+ */
151
+ channelSubscription?: 'auto' | 'manual' | string[];
152
+ /**
153
+ * Optional install metadata for build/deploy tooling (e.g. connectome-cook).
154
+ * Ignored by the recipe loader at runtime — agents don't need this; only
155
+ * tools that produce deployable artifacts (Docker images, etc.) do.
156
+ */
157
+ source?: RecipeMcpServerSource;
158
+ /**
159
+ * Auxiliary credential / config files this MCP server needs at runtime
160
+ * (e.g. `.zuliprc` for the Zulip adapter, `~/.netrc` for HTTP-auth APIs,
161
+ * a `gcloud.json` service-account key for Google APIs). Build tooling
162
+ * collects values from the operator (env vars or interactive prompts),
163
+ * writes the file, and bind-mounts it into the container. The recipe
164
+ * loader itself does NOT touch these — the runtime reads the file via
165
+ * whatever path the MCP server expects (typically declared in `env`).
166
+ *
167
+ * Like `source`, this is build-tooling metadata. Ignored at runtime.
168
+ */
169
+ credentialFiles?: RecipeCredentialFile[];
170
+ }
171
+
172
+ /**
173
+ * How to obtain and install an MCP server at deploy time. Consumed by
174
+ * build tooling like connectome-cook. All fields optional-at-the-schema-
175
+ * layer except `url`; tools may require more depending on the install
176
+ * pattern they're generating.
177
+ */
178
+ export interface RecipeMcpServerSource {
179
+ /** Git URL to clone from. */
180
+ url: string;
181
+ /**
182
+ * Git ref: branch, tag, or commit SHA. Default: "main".
183
+ * If the value starts with "refs/" (e.g. "refs/pull/3/head"), it's
184
+ * treated as an explicit refspec — tools fetch it then check out
185
+ * FETCH_HEAD. Useful for tracking PR heads on GitHub/GitLab.
186
+ */
187
+ ref?: string;
188
+ /**
189
+ * How to install inside the cloned dir at build time.
190
+ * "npm" runs `npm install && npm run build`; implies node runtime.
191
+ * "pip-editable" creates a venv at `.venv/` and runs `pip install -e .`;
192
+ * implies python3 runtime.
193
+ * { run, runtime } runs an arbitrary shell command from the cloned dir;
194
+ * `runtime` tells the tool which base packages to install
195
+ * in the image (node / python3 / custom / bun = no defaults).
196
+ * Omit entirely for sources that need no build step (e.g. the tool is
197
+ * fetched at runtime by npx/uvx — the recipe's `command` is enough).
198
+ */
199
+ install?:
200
+ | 'npm'
201
+ | 'pip-editable'
202
+ | { run: string; runtime: 'node' | 'python3' | 'custom' | 'bun' };
203
+ /**
204
+ * Build-arg name for an auth token if the URL is private.
205
+ * Tools consuming `source` should mount this via BuildKit secrets
206
+ * (so the token doesn't leak into image layers). The same env var
207
+ * name is typically already needed at runtime by the operator's
208
+ * `.env`, so this field doesn't introduce new secret surface.
209
+ */
210
+ authSecret?: string;
211
+ /**
212
+ * True if the URL's TLS cert isn't in the container's trust store
213
+ * (e.g. self-signed internal CAs on private GitLab/Gitea). Tools
214
+ * should add `-c http.sslVerify=false` to the git clone of this source.
215
+ * Prefer installing a proper CA bundle in the image over using this flag.
216
+ */
217
+ sslBypass?: boolean;
218
+ /**
219
+ * Override the in-container path of the installed source. Default:
220
+ * `/<basename-of-url-without-.git-suffix>` (e.g. the URL
221
+ * `https://github.com/x/zulip_mcp.git` places the source at `/zulip_mcp`).
222
+ */
223
+ inContainer?: { path: string };
224
+ /**
225
+ * Extra apt packages this source needs in the runtime image (e.g.
226
+ * `ffmpeg`/`curl` for a tool that shells out to them). Build tooling
227
+ * appends these to the runtime stage's apt install; connectome-host
228
+ * ignores the field at runtime (it doesn't build images).
229
+ */
230
+ systemPackages?: string[];
231
+ }
232
+
233
+ /**
234
+ * One auxiliary credential or config file the MCP server reads at runtime.
235
+ * Build tooling (cook, etc.) prompts the operator for the field values,
236
+ * serializes them in the declared format, and writes the file at the
237
+ * declared path so the bind-mounted file appears in the container.
238
+ *
239
+ * Example for Zulip's `.zuliprc`:
240
+ * ```json
241
+ * {
242
+ * "path": "./.zuliprc",
243
+ * "format": "ini",
244
+ * "section": "api",
245
+ * "mode": "0600",
246
+ * "fields": [
247
+ * { "name": "email", "envOverride": "ZULIP_EMAIL", "placeholder": "bot@example.zulipchat.com" },
248
+ * { "name": "key", "envOverride": "ZULIP_KEY", "placeholder": "abc123...", "secret": true },
249
+ * { "name": "site", "envOverride": "ZULIP_SITE", "placeholder": "https://example.zulipchat.com" }
250
+ * ]
251
+ * }
252
+ * ```
253
+ */
254
+ export interface RecipeCredentialFile {
255
+ /** Where the runtime expects the file. In a Docker context this also
256
+ * determines the bind-mount target; the host-side path is `<outDir>` +
257
+ * basename(path). Relative paths resolve against the conductor's CWD
258
+ * (typically `/app`); absolute paths land verbatim. */
259
+ path: string;
260
+ /** Serialization format. `ini` writes `key=value` lines under an
261
+ * optional `[section]` header; `json` writes `{ "field": "value", ... }`;
262
+ * `env` writes `KEY=value` (no quoting). */
263
+ format: 'ini' | 'json' | 'env';
264
+ /** Optional INI section header (`[<section>]`) preceding the fields.
265
+ * Ignored by `json` / `env` formats. */
266
+ section?: string;
267
+ /** Filesystem mode as an octal string. Default `0600` (typical for
268
+ * credentials). Build tooling sets this on the host file before bind-
269
+ * mounting; the container inherits the mode through the mount. */
270
+ mode?: string;
271
+ /** Fields the operator must supply. */
272
+ fields: RecipeCredentialFileField[];
273
+ }
274
+
275
+ export interface RecipeCredentialFileField {
276
+ /** Field name as written in the file (e.g. `email`, `key`, `site`).
277
+ * For `ini` / `env` formats this is the literal key. For `json` it
278
+ * becomes the JSON property name. */
279
+ name: string;
280
+ /** Optional env var name that, when set in `process.env` (or supplied
281
+ * via cook's `--env-file`), substitutes for prompting. Lets the
282
+ * operator pre-set values in CI / scripted contexts. */
283
+ envOverride?: string;
284
+ /** Operator-facing description of what to enter (one short line). */
285
+ description?: string;
286
+ /** Placeholder shown next to the prompt (e.g. `bot@example.zulipchat.com`). */
287
+ placeholder?: string;
288
+ /** When true, build tooling masks the input (no echo while typing).
289
+ * Default `false`. */
290
+ secret?: boolean;
291
+ }
292
+
293
+ /**
294
+ * Subset of MountConfig exposed to recipes.
295
+ * Intentionally omits watchDebounceMs, followSymlinks, and maxFileSize —
296
+ * these are implementation details best left to framework defaults.
297
+ */
298
+ export interface RecipeWorkspaceMount {
299
+ name: string;
300
+ path: string;
301
+ mode?: 'read-write' | 'read-only';
302
+ watch?: 'always' | 'on-agent-action' | 'never';
303
+ ignore?: string[];
304
+ /**
305
+ * Request inference when files in this mount change. Pair with
306
+ * `watch: 'always'` so chokidar actually observes the mount.
307
+ * - `true` — any of created | modified | deleted
308
+ * - array — only the listed ops
309
+ */
310
+ wakeOnChange?: boolean | Array<'created' | 'modified' | 'deleted'>;
311
+ /**
312
+ * Persist every write/edit/delete to disk immediately. Required for mounts
313
+ * shared across agents as a communication channel (tickets, reports, etc.)
314
+ * so other agents' chokidar watchers actually see the change.
315
+ */
316
+ autoMaterialize?: boolean;
317
+ }
318
+
319
+ export interface RecipeModules {
320
+ subagents?: boolean | { defaultModel?: string; defaultMaxTokens?: number };
321
+ lessons?: boolean;
322
+ retrieval?: boolean | { model?: string; maxInjected?: number };
323
+ wake?: boolean | import('@animalabs/agent-framework').GateConfig;
324
+ workspace?: boolean | { mounts: RecipeWorkspaceMount[]; configMount?: boolean };
325
+ /**
326
+ * Surface agent composition activity (typing indicators) to one or more
327
+ * MCPL channels while inference is active. Opt-in per recipe; channel IDs
328
+ * use the MCPL format (e.g. `zulip:tracker-miner-f`). The agent can also
329
+ * adjust the set at runtime via the `activity:show_in` / `activity:hide_in`
330
+ * tools.
331
+ */
332
+ activity?: boolean | { channels?: string[] };
333
+ /**
334
+ * MCPL self-administration. Off by default. When enabled, the agent gets
335
+ * `mcpl_list` / `mcpl_deploy` / `mcpl_restart` / `mcpl_unload` tools to
336
+ * hot-manage its own MCPL servers without a host restart. Deployments
337
+ * persist to `mcpl-servers.agent.json` (the agent overlay), merged over the
338
+ * recipe/file server list at startup.
339
+ *
340
+ * SECURITY: `mcpl_deploy` spawns arbitrary commands as the host user —
341
+ * enabling this module means trusting the agent with code execution.
342
+ */
343
+ mcplAdmin?: boolean;
344
+ /**
345
+ * Cross-process child fleet. When true (shorthand), FleetModule is attached
346
+ * with no pre-configured children. When an object, declares children the
347
+ * conductor supervises (auto-started by default on framework start) and
348
+ * optionally an allowlist of recipe paths the conductor may spawn at will.
349
+ * Recipes outside the allowlist require user approval.
350
+ *
351
+ * Recipe path resolution: relative paths in `children[].recipe` are resolved
352
+ * at load time against the directory of the parent recipe file (or URL
353
+ * base), NOT the process CWD. This lets a recipe bundle live anywhere on
354
+ * disk and reference its siblings portably. Absolute paths and http(s)
355
+ * URLs pass through unchanged.
356
+ *
357
+ * Note: this differs from runtime paths (workspace mounts, `dataDir`),
358
+ * which stay CWD-relative because they describe where the running process
359
+ * puts its state.
360
+ */
361
+ fleet?: boolean | RecipeFleet;
362
+
363
+ /**
364
+ * Web admin UI. Off by default. The default bind is 0.0.0.0:7340 (connectome
365
+ * deployments are remote, not local), which hard-requires Basic-Auth: a
366
+ * recipe that enables webui MUST supply `basicAuth`, or the host refuses to
367
+ * start. Credentials should be `${VAR}`-substituted from .env, never literal.
368
+ *
369
+ * For remote admin, front this with a reverse proxy (Caddy/nginx) for TLS;
370
+ * Basic-Auth is still required at the app layer. Set `host: '127.0.0.1'` to
371
+ * bind loopback-only for local development, which skips the auth requirement.
372
+ */
373
+ webui?: boolean | RecipeWebUi;
374
+
375
+ /**
376
+ * Auto-unsubscribe noisy ambient channels. On by default. Per subscribed
377
+ * channel, the host counts ambient (non-mention, non-DM) characters since the
378
+ * agent's last activation; when a channel crosses its limit before the next
379
+ * activation, it's auto-unsubscribed (delivery stops, and the surface starts
380
+ * tracking what's then missed). Counters reset on every activation — the
381
+ * agent sees all subscribed channels in context, compressed if large — and
382
+ * persist across restarts. The agent can override the per-channel limit at
383
+ * runtime via the `subscription-gc--set_channel_idle_limit` tool.
384
+ *
385
+ * `false` disables entirely. `defaultLimitChars` sets the global default
386
+ * (20000 if omitted). `serverId`/`toolPrefix` target the MCPL surface that
387
+ * owns subscriptions (defaults: `discord` / `mcpl--discord`).
388
+ */
389
+ subscriptionGc?:
390
+ | boolean
391
+ | { defaultLimitChars?: number; serverId?: string; toolPrefix?: string };
392
+
393
+ /**
394
+ * One-step channel attention modes (the `set_channel_mode` tool): flip a
395
+ * channel between mentions-only and every-message-debounced. Debounced mode
396
+ * bundles subscribe + a per-channel ambient debounce gate rule + pinning
397
+ * subscription-gc off; mentions mode reverses all three. On by default when
398
+ * the gate (`wake`) is enabled — it only ADDS a tool, inert until called.
399
+ * `false` disables. `serverId`/`toolPrefix` target the MCPL surface that owns
400
+ * subscriptions (defaults: `discord` / `mcpl--discord`); `defaultDebounceMs`
401
+ * sets the debounced-mode quiet window (180000 if omitted).
402
+ */
403
+ channelMode?:
404
+ | boolean
405
+ | { serverId?: string; toolPrefix?: string; gcModuleName?: string; defaultDebounceMs?: number };
406
+ }
407
+
408
+ export interface RecipeWebUi {
409
+ port?: number;
410
+ host?: string;
411
+ /**
412
+ * Basic-Auth credentials. Required whenever the bind host is non-loopback
413
+ * (which is the default). `${VAR}`-substitutable from .env.
414
+ */
415
+ basicAuth?: { username: string; password: string };
416
+ /**
417
+ * Override the default Origin allowlist for the WebSocket upgrade. Default
418
+ * is `http(s)://127.0.0.1:<port>` and `http(s)://localhost:<port>`. Add
419
+ * your reverse-proxy origin (e.g. `https://admin.example.com`) here when
420
+ * fronted by Caddy/nginx. Pass `[]` to disable the Origin check entirely
421
+ * — only sensible when something else upstream is enforcing it.
422
+ */
423
+ allowedOrigins?: string[];
424
+ }
425
+
426
+ export interface RecipeFleet {
427
+ /** Children to manage. autoStart children launch when the framework starts. */
428
+ children?: RecipeFleetChild[];
429
+ /**
430
+ * Allowlist for `fleet--launch`. If omitted, the list is implicitly the
431
+ * set of recipe paths named in `children`. A launch call targeting a
432
+ * recipe outside the allowlist fails with an error the agent should
433
+ * relay to the user (who can then approve and re-issue).
434
+ *
435
+ * Pattern syntax (intentionally minimal to keep matcher and intent aligned):
436
+ * - Literal exact match: `"recipes/miner.json"`
437
+ * - Single `"*"`: allow everything
438
+ * - Trailing `"*"` only (prefix match): `"recipes/*"` matches any path
439
+ * under `recipes/`. A mid-string `*` is rejected at validation time
440
+ * — it would look like a glob but behave only as a literal.
441
+ */
442
+ allowedRecipes?: string[];
443
+ /** Default subscription sent to each child at handshake if they don't specify their own. */
444
+ defaultSubscription?: string[];
445
+ /**
446
+ * Spawn-readiness timeouts forwarded to FleetModule. Bump these when a
447
+ * child's startup work (heavy MCP servers, large state load) won't fit
448
+ * inside the FleetModule defaults (15s / 10s / 10s / 5s respectively).
449
+ */
450
+ socketWaitTimeoutMs?: number;
451
+ readyTimeoutMs?: number;
452
+ gracefulShutdownMs?: number;
453
+ sigtermEscalationMs?: number;
454
+ }
455
+
456
+ export interface RecipeFleetChild {
457
+ name: string;
458
+ /**
459
+ * Recipe path or http(s) URL. Relative paths are resolved at `loadRecipe`
460
+ * time against the directory of the parent recipe file (or URL base), so
461
+ * a sibling recipe is referenced by its filename regardless of where the
462
+ * parent is launched from. Absolute paths and URLs pass through
463
+ * unchanged.
464
+ */
465
+ recipe: string;
466
+ /** Data dir override; default `./data/<name>`. */
467
+ dataDir?: string;
468
+ /** Env overrides on top of parent env. */
469
+ env?: Record<string, string>;
470
+ /**
471
+ * Event subscription at handshake (supports glob like `tool:*`).
472
+ * Relevant synthetic events the parent may care about:
473
+ * - `lifecycle` (includes `ready` / `idle` / `exiting`) — required for fleet--await.
474
+ * - `inference:speech` — per-final-inference speech text, required for fleet--relay.
475
+ * - `inference:completed` / `inference:failed` / `tool:completed` / `tool:failed` — useful for status tracking.
476
+ */
477
+ subscription?: string[];
478
+ /** Launch this child on framework start. Default: true. */
479
+ autoStart?: boolean;
480
+ /** Respawn on crash (Phase 5 honours this; schema accepts it now). */
481
+ autoRestart?: boolean;
482
+ }
483
+
484
+ export interface Recipe {
485
+ name: string;
486
+ description?: string;
487
+ version?: string;
488
+ agent: RecipeAgent;
489
+ mcpServers?: Record<string, RecipeMcpServer>;
490
+ modules?: RecipeModules;
491
+ sessionNaming?: { examples?: string[] };
492
+ }
493
+
494
+ // ---------------------------------------------------------------------------
495
+ // Default recipe
496
+ // ---------------------------------------------------------------------------
497
+
498
+ export const DEFAULT_RECIPE: Recipe = {
499
+ name: 'Agent',
500
+ description: 'General-purpose assistant with tool access',
501
+ agent: {
502
+ name: 'agent',
503
+ systemPrompt: [
504
+ 'You are a helpful assistant. You have access to tools provided by connected MCP servers.',
505
+ 'Use them to help the user with their tasks.',
506
+ '',
507
+ 'You can fork subagents for parallel work, create persistent notes, and write files to `products/` as outputs of your work.',
508
+ ].join('\n'),
509
+ },
510
+ modules: {
511
+ subagents: true,
512
+ lessons: true,
513
+ retrieval: true,
514
+ wake: true,
515
+ workspace: true,
516
+ },
517
+ };
518
+
519
+ // ---------------------------------------------------------------------------
520
+ // Environment variable substitution
521
+ // ---------------------------------------------------------------------------
522
+
523
+ /**
524
+ * Walk a parsed-JSON value tree and substitute `${VAR_NAME}` patterns in
525
+ * string values with `process.env.VAR_NAME`. Applied at recipe-load time,
526
+ * before schema validation, so secrets can live in `.env` (gitignored) while
527
+ * the recipe itself stays commit-safe.
528
+ *
529
+ * Patterns:
530
+ * - `${FOO}` — required. Throws if FOO is unset (empty string OK).
531
+ * - `${FOO:-default}` — optional. Uses FOO when set and non-empty,
532
+ * otherwise the literal default text. Default may be empty
533
+ * (`${FOO:-}`) to mean "use FOO or just empty string, never throw".
534
+ * - VAR name: `[A-Za-z_][A-Za-z0-9_]*`.
535
+ * - Default text: any chars except `}`. No nested `${...}` parsing
536
+ * and no escape syntax — keep recipes JSON-friendly.
537
+ * - Multiple occurrences in a single string are all substituted.
538
+ * - Non-string values (numbers, booleans, nulls) pass through unchanged.
539
+ * - Arrays and objects are walked recursively.
540
+ *
541
+ * No escape syntax yet — a literal `${...}` in recipe JSON is not a supported
542
+ * case. If that becomes needed, add `$$` → `$` unwrapping as a pre-pass.
543
+ */
544
+ export function substituteEnvVars(value: unknown, source: string): unknown {
545
+ if (typeof value === 'string') {
546
+ return value.replace(
547
+ /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g,
548
+ (_match, name: string, defaultValue: string | undefined) => {
549
+ const v = process.env[name];
550
+ if (defaultValue !== undefined) {
551
+ // ${VAR:-default} — use VAR if set + non-empty, else default.
552
+ return v !== undefined && v !== '' ? v : defaultValue;
553
+ }
554
+ // ${VAR} — required; empty string is allowed if explicitly set.
555
+ if (v === undefined) {
556
+ throw new Error(
557
+ `Recipe "${source}" references environment variable \${${name}} which is not set. ` +
558
+ `Add it to your .env file, supply a default with \${${name}:-...}, ` +
559
+ `or delete the section of the recipe that uses it ` +
560
+ `(e.g. remove the mcpServers entry for a source you don't have).`,
561
+ );
562
+ }
563
+ return v;
564
+ },
565
+ );
566
+ }
567
+ if (Array.isArray(value)) {
568
+ return value.map((v) => substituteEnvVars(v, source));
569
+ }
570
+ if (value !== null && typeof value === 'object') {
571
+ const out: Record<string, unknown> = {};
572
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
573
+ out[k] = substituteEnvVars(v, source);
574
+ }
575
+ return out;
576
+ }
577
+ return value;
578
+ }
579
+
580
+ // ---------------------------------------------------------------------------
581
+ // Loading
582
+ // ---------------------------------------------------------------------------
583
+
584
+ /**
585
+ * Base for resolving recipe-relative paths (currently: `children[].recipe`).
586
+ * `file` sources use the recipe file's directory; `url` sources use the URL
587
+ * base so a child like `"child.json"` on an `https://example.com/parent.json`
588
+ * load resolves to `https://example.com/child.json`.
589
+ */
590
+ type RecipeSourceBase = { kind: 'file'; dir: string } | { kind: 'url'; base: string };
591
+
592
+ /**
593
+ * Load a recipe from a URL or local file path.
594
+ * If the systemPrompt value is an HTTP(S) URL, fetches the text.
595
+ * Recipe string values containing `${VAR}` patterns are substituted against
596
+ * `process.env` before validation — see substituteEnvVars().
597
+ * Relative `modules.fleet.children[].recipe` paths are resolved against the
598
+ * parent recipe's directory (or URL base) so sibling recipes are portable.
599
+ */
600
+ export async function loadRecipe(source: string): Promise<Recipe> {
601
+ let raw: unknown;
602
+ let sourceBase: RecipeSourceBase;
603
+
604
+ if (source.startsWith('http://') || source.startsWith('https://')) {
605
+ const res = await fetch(source);
606
+ if (!res.ok) throw new Error(`Failed to fetch recipe from ${source}: ${res.status} ${res.statusText}`);
607
+ raw = await res.json();
608
+ sourceBase = { kind: 'url', base: source };
609
+ } else {
610
+ const path = resolve(source);
611
+ if (!existsSync(path)) throw new Error(`Recipe file not found: ${path}`);
612
+ raw = JSON.parse(readFileSync(path, 'utf-8'));
613
+ sourceBase = { kind: 'file', dir: dirname(path) };
614
+ }
615
+
616
+ raw = substituteEnvVars(raw, source);
617
+ const recipe = validateRecipe(raw);
618
+ resolveChildRecipePaths(recipe, sourceBase);
619
+ return resolveSystemPrompt(recipe);
620
+ }
621
+
622
+ /**
623
+ * Resolve a single `children[].recipe` value against the parent recipe's
624
+ * source base. Returns unchanged if absolute or http(s) URL.
625
+ */
626
+ export function resolveRecipeRelative(child: string, base: RecipeSourceBase): string {
627
+ if (child.startsWith('http://') || child.startsWith('https://')) return child;
628
+ if (isAbsolute(child)) return child;
629
+ if (base.kind === 'file') return resolve(base.dir, child);
630
+ // URL base: resolve the child against the parent URL.
631
+ return new URL(child, base.base).href;
632
+ }
633
+
634
+ function resolveChildRecipePaths(recipe: Recipe, base: RecipeSourceBase): void {
635
+ const fleet = recipe.modules?.fleet;
636
+ if (!fleet || typeof fleet !== 'object') return;
637
+ if (!fleet.children) return;
638
+ for (const child of fleet.children) {
639
+ child.recipe = resolveRecipeRelative(child.recipe, base);
640
+ }
641
+ }
642
+
643
+ /**
644
+ * If systemPrompt is an HTTP(S) URL, fetch its contents as plain text.
645
+ * Only treated as a URL if it looks like a single URL (no spaces/newlines).
646
+ */
647
+ async function resolveSystemPrompt(recipe: Recipe): Promise<Recipe> {
648
+ const prompt = recipe.agent.systemPrompt;
649
+ const isUrl = (prompt.startsWith('http://') || prompt.startsWith('https://'))
650
+ && !prompt.includes(' ') && !prompt.includes('\n');
651
+ if (isUrl) {
652
+ const res = await fetch(prompt);
653
+ if (!res.ok) throw new Error(`Failed to fetch system prompt from ${prompt}: ${res.status} ${res.statusText}`);
654
+ return {
655
+ ...recipe,
656
+ agent: { ...recipe.agent, systemPrompt: await res.text() },
657
+ };
658
+ }
659
+ return recipe;
660
+ }
661
+
662
+ /**
663
+ * Validate raw JSON and fill defaults.
664
+ */
665
+ export function validateRecipe(raw: unknown): Recipe {
666
+ if (!raw || typeof raw !== 'object') throw new Error('Recipe must be a JSON object');
667
+ const obj = raw as Record<string, unknown>;
668
+
669
+ if (typeof obj.name !== 'string' || !obj.name) {
670
+ throw new Error('Recipe must have a "name" string');
671
+ }
672
+ if (!obj.agent || typeof obj.agent !== 'object') {
673
+ throw new Error('Recipe must have an "agent" object');
674
+ }
675
+
676
+ const agent = obj.agent as Record<string, unknown>;
677
+ if (typeof agent.systemPrompt !== 'string' || !agent.systemPrompt) {
678
+ throw new Error('Recipe agent must have a "systemPrompt" string');
679
+ }
680
+
681
+ if (agent.maxStreamTokens !== undefined && (typeof agent.maxStreamTokens !== 'number' || agent.maxStreamTokens <= 0)) {
682
+ throw new Error('Recipe agent.maxStreamTokens must be a positive number.');
683
+ }
684
+
685
+ // Recipes are runtime JSON; a typo'd TTL ("1hr", "60m") would otherwise
686
+ // surface as a 400 from Anthropic at the agent's first inference.
687
+ if (agent.cacheTtl !== undefined && agent.cacheTtl !== '5m' && agent.cacheTtl !== '1h') {
688
+ throw new Error(`Recipe agent.cacheTtl must be '5m' or '1h', got ${JSON.stringify(agent.cacheTtl)}.`);
689
+ }
690
+
691
+ // Validate agent.thinking if present. Catches typos and constraint
692
+ // violations (notably max_tokens > budget_tokens) at recipe-load time
693
+ // rather than as a 400 from Anthropic at first inference.
694
+ if (agent.thinking !== undefined) {
695
+ if (!agent.thinking || typeof agent.thinking !== 'object' || Array.isArray(agent.thinking)) {
696
+ throw new Error('Recipe agent.thinking must be an object with "enabled" and optional "budgetTokens".');
697
+ }
698
+ const thinking = agent.thinking as Record<string, unknown>;
699
+ if (typeof thinking.enabled !== 'boolean') {
700
+ throw new Error('Recipe agent.thinking.enabled must be a boolean.');
701
+ }
702
+ if (thinking.budgetTokens !== undefined && (typeof thinking.budgetTokens !== 'number' || thinking.budgetTokens <= 0)) {
703
+ throw new Error('Recipe agent.thinking.budgetTokens must be a positive number.');
704
+ }
705
+ if (thinking.enabled === true && typeof thinking.budgetTokens === 'number' && typeof agent.maxTokens === 'number') {
706
+ if (thinking.budgetTokens >= agent.maxTokens) {
707
+ throw new Error(
708
+ `Recipe agent.thinking.budgetTokens (${thinking.budgetTokens}) must be less than agent.maxTokens (${agent.maxTokens}) ` +
709
+ `(Anthropic requires max_tokens > thinking.budget_tokens).`,
710
+ );
711
+ }
712
+ }
713
+ }
714
+
715
+ // Validate strategy type if present
716
+ if (agent.strategy) {
717
+ const strategy = agent.strategy as Record<string, unknown>;
718
+ if (
719
+ strategy.type &&
720
+ strategy.type !== 'autobiographical' &&
721
+ strategy.type !== 'passthrough' &&
722
+ strategy.type !== 'frontdesk'
723
+ ) {
724
+ throw new Error(
725
+ `Invalid strategy type "${strategy.type}". Must be "autobiographical", "passthrough", or "frontdesk".`,
726
+ );
727
+ }
728
+ }
729
+
730
+ // Validate mcpServers entries if present
731
+ if (obj.mcpServers && typeof obj.mcpServers === 'object') {
732
+ for (const [id, entry] of Object.entries(obj.mcpServers as Record<string, unknown>)) {
733
+ if (!entry || typeof entry !== 'object') {
734
+ throw new Error(`mcpServers.${id} must be an object`);
735
+ }
736
+ const server = entry as Record<string, unknown>;
737
+ const hasCommand = typeof server.command === 'string' && server.command;
738
+ const hasUrl = typeof server.url === 'string' && server.url;
739
+ if (!hasCommand && !hasUrl) {
740
+ throw new Error(`mcpServers.${id} must have a "command" string (stdio) or "url" string (websocket)`);
741
+ }
742
+ if (server.args !== undefined && !Array.isArray(server.args)) {
743
+ throw new Error(`mcpServers.${id}.args must be an array`);
744
+ }
745
+ if (server.source !== undefined) {
746
+ if (typeof server.source !== 'object' || server.source === null) {
747
+ throw new Error(`mcpServers.${id}.source must be an object`);
748
+ }
749
+ const src = server.source as Record<string, unknown>;
750
+ if (typeof src.url !== 'string' || !src.url) {
751
+ throw new Error(`mcpServers.${id}.source.url must be a non-empty string`);
752
+ }
753
+ if (src.ref !== undefined && typeof src.ref !== 'string') {
754
+ throw new Error(`mcpServers.${id}.source.ref must be a string`);
755
+ }
756
+ if (src.install !== undefined) {
757
+ const install = src.install;
758
+ const isShorthand = install === 'npm' || install === 'pip-editable';
759
+ const isCustom =
760
+ typeof install === 'object' && install !== null
761
+ && typeof (install as Record<string, unknown>).run === 'string'
762
+ && ['node', 'python3', 'custom', 'bun'].includes(
763
+ (install as Record<string, unknown>).runtime as string,
764
+ );
765
+ if (!isShorthand && !isCustom) {
766
+ throw new Error(
767
+ `mcpServers.${id}.source.install must be 'npm', 'pip-editable', ` +
768
+ `or { run: string, runtime: 'node' | 'python3' | 'custom' | 'bun' }`,
769
+ );
770
+ }
771
+ }
772
+ if (src.authSecret !== undefined && typeof src.authSecret !== 'string') {
773
+ throw new Error(`mcpServers.${id}.source.authSecret must be a string`);
774
+ }
775
+ if (src.sslBypass !== undefined && typeof src.sslBypass !== 'boolean') {
776
+ throw new Error(`mcpServers.${id}.source.sslBypass must be a boolean`);
777
+ }
778
+ if (src.systemPackages !== undefined) {
779
+ // The regex bounds these to Debian package names — build tooling
780
+ // pastes them into an `apt-get install` line, so an unbounded string
781
+ // is a command-injection vector. Kept in sync with cook's vendored
782
+ // copy (connectome-cook src/vendor/recipe.ts).
783
+ if (!Array.isArray(src.systemPackages)
784
+ || !(src.systemPackages as unknown[]).every(
785
+ (p) => typeof p === 'string' && /^[a-z0-9][a-z0-9+.\-]+$/.test(p),
786
+ )) {
787
+ throw new Error(`mcpServers.${id}.source.systemPackages must be an array of Debian package names`);
788
+ }
789
+ }
790
+ if (src.inContainer !== undefined) {
791
+ if (typeof src.inContainer !== 'object' || src.inContainer === null) {
792
+ throw new Error(`mcpServers.${id}.source.inContainer must be an object`);
793
+ }
794
+ if (typeof (src.inContainer as Record<string, unknown>).path !== 'string') {
795
+ throw new Error(`mcpServers.${id}.source.inContainer.path must be a string`);
796
+ }
797
+ }
798
+ }
799
+ for (const field of ['enabledTools', 'disabledTools'] as const) {
800
+ if (server[field] === undefined) continue;
801
+ if (!Array.isArray(server[field]) || !(server[field] as unknown[]).every((p) => typeof p === 'string' && p)) {
802
+ throw new Error(`mcpServers.${id}.${field} must be an array of non-empty strings`);
803
+ }
804
+ }
805
+ if (server.credentialFiles !== undefined) {
806
+ if (!Array.isArray(server.credentialFiles)) {
807
+ throw new Error(`mcpServers.${id}.credentialFiles must be an array`);
808
+ }
809
+ const seenPaths = new Set<string>();
810
+ for (let i = 0; i < server.credentialFiles.length; i++) {
811
+ const cf = server.credentialFiles[i] as Record<string, unknown>;
812
+ if (!cf || typeof cf !== 'object') {
813
+ throw new Error(`mcpServers.${id}.credentialFiles[${i}] must be an object`);
814
+ }
815
+ if (typeof cf.path !== 'string' || !cf.path) {
816
+ throw new Error(`mcpServers.${id}.credentialFiles[${i}].path must be a non-empty string`);
817
+ }
818
+ if (seenPaths.has(cf.path)) {
819
+ throw new Error(`mcpServers.${id}.credentialFiles[${i}].path "${cf.path}" is duplicated within the same server`);
820
+ }
821
+ seenPaths.add(cf.path);
822
+ if (cf.format !== 'ini' && cf.format !== 'json' && cf.format !== 'env') {
823
+ throw new Error(`mcpServers.${id}.credentialFiles[${i}].format must be 'ini', 'json', or 'env'`);
824
+ }
825
+ if (cf.section !== undefined && typeof cf.section !== 'string') {
826
+ throw new Error(`mcpServers.${id}.credentialFiles[${i}].section must be a string`);
827
+ }
828
+ if (cf.mode !== undefined && (typeof cf.mode !== 'string' || !/^0?[0-7]{3,4}$/.test(cf.mode))) {
829
+ throw new Error(`mcpServers.${id}.credentialFiles[${i}].mode must be an octal string like "0600"`);
830
+ }
831
+ if (!Array.isArray(cf.fields) || cf.fields.length === 0) {
832
+ throw new Error(`mcpServers.${id}.credentialFiles[${i}].fields must be a non-empty array`);
833
+ }
834
+ const seenFieldNames = new Set<string>();
835
+ for (let j = 0; j < cf.fields.length; j++) {
836
+ const f = cf.fields[j] as Record<string, unknown>;
837
+ if (!f || typeof f !== 'object') {
838
+ throw new Error(`mcpServers.${id}.credentialFiles[${i}].fields[${j}] must be an object`);
839
+ }
840
+ if (typeof f.name !== 'string' || !f.name) {
841
+ throw new Error(`mcpServers.${id}.credentialFiles[${i}].fields[${j}].name must be a non-empty string`);
842
+ }
843
+ if (seenFieldNames.has(f.name)) {
844
+ throw new Error(`mcpServers.${id}.credentialFiles[${i}].fields[${j}].name "${f.name}" is duplicated`);
845
+ }
846
+ seenFieldNames.add(f.name);
847
+ if (f.envOverride !== undefined && (typeof f.envOverride !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(f.envOverride))) {
848
+ throw new Error(`mcpServers.${id}.credentialFiles[${i}].fields[${j}].envOverride must be a valid env var name`);
849
+ }
850
+ for (const optStr of ['description', 'placeholder'] as const) {
851
+ if (f[optStr] !== undefined && typeof f[optStr] !== 'string') {
852
+ throw new Error(`mcpServers.${id}.credentialFiles[${i}].fields[${j}].${optStr} must be a string`);
853
+ }
854
+ }
855
+ if (f.secret !== undefined && typeof f.secret !== 'boolean') {
856
+ throw new Error(`mcpServers.${id}.credentialFiles[${i}].fields[${j}].secret must be a boolean`);
857
+ }
858
+ }
859
+ }
860
+ }
861
+ }
862
+ }
863
+
864
+ // Validate workspace mounts if present
865
+ if (obj.modules && typeof obj.modules === 'object') {
866
+ const mods = obj.modules as Record<string, unknown>;
867
+ if (mods.workspace && typeof mods.workspace === 'object') {
868
+ const ws = mods.workspace as Record<string, unknown>;
869
+ if (!Array.isArray(ws.mounts) || ws.mounts.length === 0) {
870
+ throw new Error('workspace.mounts must be a non-empty array');
871
+ }
872
+ for (let i = 0; i < ws.mounts.length; i++) {
873
+ const m = ws.mounts[i] as Record<string, unknown>;
874
+ if (!m || typeof m !== 'object') {
875
+ throw new Error(`workspace.mounts[${i}] must be an object`);
876
+ }
877
+ if (typeof m.name !== 'string' || !m.name) {
878
+ throw new Error(`workspace.mounts[${i}].name must be a non-empty string`);
879
+ }
880
+ if (typeof m.path !== 'string' || !m.path) {
881
+ throw new Error(`workspace.mounts[${i}].path must be a non-empty string`);
882
+ }
883
+ if (m.mode !== undefined && m.mode !== 'read-write' && m.mode !== 'read-only') {
884
+ throw new Error(`workspace.mounts[${i}].mode must be "read-write" or "read-only"`);
885
+ }
886
+ }
887
+ }
888
+
889
+ // Validate fleet if present (object form only — boolean is trivially valid).
890
+ if (mods.fleet && typeof mods.fleet === 'object') {
891
+ const fleet = mods.fleet as Record<string, unknown>;
892
+ if (fleet.children !== undefined) {
893
+ if (!Array.isArray(fleet.children)) {
894
+ throw new Error('fleet.children must be an array');
895
+ }
896
+ const seenNames = new Set<string>();
897
+ for (let i = 0; i < fleet.children.length; i++) {
898
+ const c = fleet.children[i] as Record<string, unknown>;
899
+ if (!c || typeof c !== 'object') {
900
+ throw new Error(`fleet.children[${i}] must be an object`);
901
+ }
902
+ if (typeof c.name !== 'string' || !c.name) {
903
+ throw new Error(`fleet.children[${i}].name must be a non-empty string`);
904
+ }
905
+ if (seenNames.has(c.name)) {
906
+ throw new Error(`fleet.children[${i}].name "${c.name}" is duplicated`);
907
+ }
908
+ seenNames.add(c.name);
909
+ if (typeof c.recipe !== 'string' || !c.recipe) {
910
+ throw new Error(`fleet.children[${i}].recipe must be a non-empty string`);
911
+ }
912
+ if (c.subscription !== undefined && !Array.isArray(c.subscription)) {
913
+ throw new Error(`fleet.children[${i}].subscription must be an array of strings`);
914
+ }
915
+ if (c.autoStart !== undefined && typeof c.autoStart !== 'boolean') {
916
+ throw new Error(`fleet.children[${i}].autoStart must be a boolean`);
917
+ }
918
+ }
919
+ }
920
+ if (fleet.allowedRecipes !== undefined) {
921
+ if (!Array.isArray(fleet.allowedRecipes) || !fleet.allowedRecipes.every((r) => typeof r === 'string')) {
922
+ throw new Error('fleet.allowedRecipes must be an array of strings');
923
+ }
924
+ // Prefix-match-only: reject mid-string `*` so the pattern can't look
925
+ // like a glob while silently behaving as a literal.
926
+ for (const pattern of fleet.allowedRecipes as string[]) {
927
+ if (pattern === '*' || !pattern.includes('*')) continue;
928
+ if (pattern.indexOf('*') !== pattern.length - 1) {
929
+ throw new Error(
930
+ `fleet.allowedRecipes entry "${pattern}" has a mid-string "*". ` +
931
+ `Only trailing "*" (prefix match) or a bare "*" (allow all) are supported.`,
932
+ );
933
+ }
934
+ }
935
+ }
936
+ if (fleet.defaultSubscription !== undefined) {
937
+ if (!Array.isArray(fleet.defaultSubscription) || !fleet.defaultSubscription.every((s) => typeof s === 'string')) {
938
+ throw new Error('fleet.defaultSubscription must be an array of strings');
939
+ }
940
+ }
941
+ for (const k of ['socketWaitTimeoutMs', 'readyTimeoutMs', 'gracefulShutdownMs', 'sigtermEscalationMs'] as const) {
942
+ if (fleet[k] !== undefined && (typeof fleet[k] !== 'number' || (fleet[k] as number) < 0)) {
943
+ throw new Error(`fleet.${k} must be a non-negative number`);
944
+ }
945
+ }
946
+ }
947
+ }
948
+
949
+ return obj as unknown as Recipe;
950
+ }
951
+
952
+ // ---------------------------------------------------------------------------
953
+ // Persistence
954
+ // ---------------------------------------------------------------------------
955
+
956
+ function savedRecipePath(dataDir: string): string {
957
+ return resolve(dataDir, '.recipe.json');
958
+ }
959
+
960
+ export function saveRecipe(dataDir: string, recipe: Recipe): void {
961
+ mkdirSync(dataDir, { recursive: true });
962
+ writeFileSync(savedRecipePath(dataDir), JSON.stringify(recipe, null, 2) + '\n', 'utf-8');
963
+ }
964
+
965
+ export function loadSavedRecipe(dataDir: string): Recipe | null {
966
+ const path = savedRecipePath(dataDir);
967
+ if (!existsSync(path)) return null;
968
+ try {
969
+ const raw = JSON.parse(readFileSync(path, 'utf-8'));
970
+ return validateRecipe(raw);
971
+ } catch {
972
+ return null;
973
+ }
974
+ }
975
+
976
+ export function clearSavedRecipe(dataDir: string): void {
977
+ const path = savedRecipePath(dataDir);
978
+ if (existsSync(path)) {
979
+ unlinkSync(path);
980
+ }
981
+ }
982
+
983
+ // ---------------------------------------------------------------------------
984
+ // CLI helpers
985
+ // ---------------------------------------------------------------------------
986
+
987
+ /**
988
+ * Parse argv to find a recipe source. Returns null if none provided.
989
+ * Skips known flags (--no-tui, --no-recipe).
990
+ */
991
+ export function parseRecipeArg(argv: string[]): { source: string | null; noRecipe: boolean } {
992
+ const noRecipe = argv.includes('--no-recipe');
993
+ let source: string | null = null;
994
+
995
+ for (const arg of argv.slice(2)) {
996
+ if (arg.startsWith('--')) continue;
997
+ // First positional arg is the recipe source
998
+ source = arg;
999
+ break;
1000
+ }
1001
+
1002
+ return { source, noRecipe };
1003
+ }