@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/commands.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * Commands:
5
5
  * /undo — Revert to state before last agent turn
6
6
  * /redo — Re-apply last undone action
7
+ * /nudge [agent] — Run inference on current context (no new events)
7
8
  * /checkpoint N — Save current state as named checkpoint
8
9
  * /restore N — Branch from checkpoint, switch to it
9
10
  * /branches — List all Chronicle branches
@@ -35,6 +36,10 @@ import { type FleetModule, formatChildRow } from './modules/fleet-module.js';
35
36
  /** Imported lazily to avoid circular deps — index.ts re-exports the type. */
36
37
  interface AppContext {
37
38
  framework: AgentFramework;
39
+ /** Resolved main-agent name (see index.ts resolveAgentName). Optional
40
+ * because some callers (tui/webui refs) don't thread it; /puppet falls
41
+ * back to the first registered agent, same as getAgentCM. */
42
+ agentName?: string;
38
43
  sessionManager: import('./session-manager.js').SessionManager;
39
44
  recipe: Recipe;
40
45
  branchState: BranchState;
@@ -116,6 +121,35 @@ function getAgentCM(framework: AgentFramework, agentName?: string): ContextManag
116
121
  return all[0]?.getContextManager() ?? null;
117
122
  }
118
123
 
124
+ /**
125
+ * Refuse head-moving commands while the main agent's turn is in flight.
126
+ *
127
+ * Moving the Chronicle head (undo/redo/checkout/restore/branchto/newtopic)
128
+ * is not atomic with respect to a streaming generation: the in-flight reply
129
+ * commits onto whatever branch is current WHEN IT COMPLETES, so a head move
130
+ * mid-stream detaches the reply from its request — it lands on the new
131
+ * branch attached to the wrong parent (orphaned node), including when the
132
+ * move is issued from a second client on the same session. The supported
133
+ * sequence is: stop the generation, then move the head.
134
+ *
135
+ * Returns null when the command may proceed. Agents without a state field
136
+ * (stubs, minimal harnesses) are treated as idle.
137
+ */
138
+ function inFlightGuard(app: AppContext, cmd: string): CommandResult | null {
139
+ const framework = app.framework;
140
+ const agent = (app.agentName ? framework.getAgent(app.agentName) : undefined)
141
+ ?? framework.getAllAgents()[0];
142
+ const status = (agent as { state?: { status?: string } } | undefined)?.state?.status;
143
+ if (status === undefined || status === 'idle') return null;
144
+ return {
145
+ lines: [
146
+ { text: `/${cmd} refused: a turn is in flight (${(agent as { name?: string }).name ?? 'agent'}: ${status}).`, style: 'system' },
147
+ { text: ' Moving the head mid-generation would attach the streaming reply to the wrong', style: 'system' },
148
+ { text: ' branch/request. Stop the generation first, then retry.', style: 'system' },
149
+ ],
150
+ };
151
+ }
152
+
119
153
  export function handleCommand(command: string, app: AppContext): CommandResult {
120
154
  const parts = command.slice(1).split(/\s+/);
121
155
  const cmd = parts[0]!;
@@ -142,14 +176,16 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
142
176
  { text: '--- Commands ---', style: 'system' },
143
177
  { text: ' /quit, /q Exit the app', style: 'system' },
144
178
  { text: ' /status Show agent status', style: 'system' },
145
- { text: ' /clear Clear conversation', style: 'system' },
179
+ { text: ' /clear Clear this client\'s display (history/context are kept)', style: 'system' },
146
180
  { text: ' /lessons Show lesson library', style: 'system' },
147
181
  { text: ' /export Export lessons to ./output/ (JSON + markdown)', style: 'system' },
148
182
  { text: ' /undo Revert last agent turn', style: 'system' },
149
183
  { text: ' /redo Re-apply undone action', style: 'system' },
150
- { text: ' /checkpoint <name> Save current state', style: 'system' },
151
- { text: ' /restore <name> Restore to checkpoint', style: 'system' },
152
- { text: ' /branches List Chronicle branches', style: 'system' },
184
+ { text: ' /nudge [agent] Run inference on current context (no new events)', style: 'system' },
185
+ { text: ' /puppet <tool> [json] Admin: execute a tool AS the agent, store the pair', style: 'system' },
186
+ { text: ' /checkpoint [name] Save current state (no name: list checkpoints)', style: 'system' },
187
+ { text: ' /restore [name] Restore to checkpoint (no name: list checkpoints)', style: 'system' },
188
+ { text: ' /branches List Chronicle branches and checkpoints', style: 'system' },
153
189
  { text: ' /checkout <name> Switch to branch', style: 'system' },
154
190
  { text: ' /history [n] Show state transitions (last n)', style: 'system' },
155
191
  { text: ' /find <text> Search messages for text', style: 'system' },
@@ -163,9 +199,9 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
163
199
  { text: ' /session Show current session', style: 'system' },
164
200
  { text: ' /session list List all sessions', style: 'system' },
165
201
  { text: ' /session new [name] Create new session', style: 'system' },
166
- { text: ' /session switch <name> Switch to session', style: 'system' },
202
+ { text: ' /session switch <name or id> Switch to session', style: 'system' },
167
203
  { text: ' /session rename <name> Rename current session', style: 'system' },
168
- { text: ' /session delete <name> Delete a session', style: 'system' },
204
+ { text: ' /session delete <name or id> --confirm Delete a session (irreversible)', style: 'system' },
169
205
  { text: ' /recipe Show current recipe info', style: 'system' },
170
206
  { text: ' /newtopic [context] Reset head window (auto-summarize if empty)', style: 'system' },
171
207
  { text: ' /usage Show session token usage and costs', style: 'system' },
@@ -175,7 +211,12 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
175
211
  };
176
212
 
177
213
  case 'clear':
178
- return { lines: [{ text: '(cleared)', style: 'system' }] };
214
+ // Display-clearing is client-side: the TUI wipes its scrollback and
215
+ // the SPA wipes its transcript view before this handler is ever
216
+ // reached. This line is only seen by surfaces with no display to
217
+ // clear (headless), where it honestly reports that nothing else —
218
+ // Chronicle, context — was touched.
219
+ return { lines: [{ text: '(display cleared on clients; history and context are kept)', style: 'system' }] };
179
220
 
180
221
  case 'status':
181
222
  return handleStatus(framework);
@@ -187,28 +228,38 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
187
228
  return handleExport(app);
188
229
 
189
230
  case 'undo':
190
- return handleUndo(app);
231
+ return inFlightGuard(app, cmd) ?? handleUndo(app);
232
+
233
+ case 'nudge':
234
+ return handleNudge(app, args[0]);
235
+
236
+ case 'puppet':
237
+ return handlePuppet(app, args);
191
238
 
192
239
  case 'redo':
193
- return handleRedo(app);
240
+ return inFlightGuard(app, cmd) ?? handleRedo(app);
194
241
 
242
+ // Name-taking commands join the REST of the line, not just the first
243
+ // token: names may contain spaces (/checkpoint my test point), and
244
+ // /session rename already accepts multi-word names — parsing them
245
+ // differently made multi-word names silently truncate here.
195
246
  case 'checkpoint':
196
- return handleCheckpoint(app, args[0]);
247
+ return handleCheckpoint(app, args.join(' ') || undefined);
197
248
 
198
249
  case 'restore':
199
- return handleRestore(app, args[0]);
250
+ return inFlightGuard(app, cmd) ?? handleRestore(app, args.join(' ') || undefined);
200
251
 
201
252
  case 'branches':
202
- return handleBranches(framework);
253
+ return handleBranches(app);
203
254
 
204
255
  case 'checkout':
205
- return handleCheckout(framework, args[0]);
256
+ return inFlightGuard(app, cmd) ?? handleCheckout(framework, args.join(' ') || undefined);
206
257
 
207
258
  case 'history':
208
259
  return handleHistory(framework, args[0]);
209
260
 
210
261
  case 'branchto':
211
- return handleBranchTo(app, args[0]);
262
+ return inFlightGuard(app, cmd) ?? handleBranchTo(app, args[0]);
212
263
 
213
264
  case 'find':
214
265
  return handleFind(framework, args.join(' '));
@@ -229,7 +280,7 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
229
280
  return handleRecipe(app);
230
281
 
231
282
  case 'newtopic':
232
- return handleNewTopic(app, args);
283
+ return inFlightGuard(app, cmd) ?? handleNewTopic(app, args);
233
284
 
234
285
  case 'usage':
235
286
  return handleUsage(app);
@@ -297,12 +348,14 @@ function handleSession(app: AppContext, args: string[]): CommandResult {
297
348
  return handleSessionNew(app, args.slice(1).join(' ') || undefined);
298
349
  case 'switch':
299
350
  case 'sw':
300
- return handleSessionSwitch(app, args[1]);
351
+ // Rest-of-line, matching rename: session names may contain spaces, and
352
+ // a session renamed to a multi-word name must stay reachable by name.
353
+ return handleSessionSwitch(app, args.slice(1).join(' ') || undefined);
301
354
  case 'rename':
302
355
  return handleSessionRename(app, args.slice(1).join(' ') || undefined);
303
356
  case 'delete':
304
357
  case 'rm':
305
- return handleSessionDelete(app, args[1]);
358
+ return handleSessionDelete(app, args.slice(1));
306
359
  default:
307
360
  return { lines: [{ text: `Unknown /session subcommand: ${sub}. Try /session list.`, style: 'system' }] };
308
361
  }
@@ -398,9 +451,17 @@ function handleSessionRename(app: AppContext, name?: string): CommandResult {
398
451
  return { lines: [{ text: `Session renamed to "${name}".`, style: 'system' }] };
399
452
  }
400
453
 
401
- function handleSessionDelete(app: AppContext, nameOrId?: string): CommandResult {
454
+ function handleSessionDelete(app: AppContext, args: string[]): CommandResult {
455
+ // Deletion is irreversible, so it takes an explicit second step: the bare
456
+ // command shows exactly what matched (name + id + message count) and asks
457
+ // for --confirm. This also defuses the truncated-name amplifier: a typo'd
458
+ // or partial name can match a DIFFERENT session, and without the echo the
459
+ // wrong one died silently.
460
+ const confirmed = args[args.length - 1] === '--confirm';
461
+ const nameOrId = (confirmed ? args.slice(0, -1) : args).join(' ') || undefined;
462
+
402
463
  if (!nameOrId) {
403
- return { lines: [{ text: 'Usage: /session delete <name or id>', style: 'system' }] };
464
+ return { lines: [{ text: 'Usage: /session delete <name or id> [--confirm]', style: 'system' }] };
404
465
  }
405
466
 
406
467
  const session = app.sessionManager.findSession(nameOrId);
@@ -408,6 +469,16 @@ function handleSessionDelete(app: AppContext, nameOrId?: string): CommandResult
408
469
  return { lines: [{ text: `Session "${nameOrId}" not found.`, style: 'system' }] };
409
470
  }
410
471
 
472
+ if (!confirmed) {
473
+ const msgs = session.messageCount !== undefined ? `, ${session.messageCount} msgs` : '';
474
+ return {
475
+ lines: [
476
+ { text: `Will delete session "${session.name}" [${session.id}]${msgs} — irreversible.`, style: 'system' },
477
+ { text: `To proceed: /session delete ${session.id} --confirm`, style: 'system' },
478
+ ],
479
+ };
480
+ }
481
+
411
482
  try {
412
483
  app.sessionManager.deleteSession(session.id);
413
484
  return { lines: [{ text: `Deleted session "${session.name}" [${session.id}].`, style: 'system' }] };
@@ -513,14 +584,16 @@ function handleBudget(framework: AgentFramework, arg?: string): CommandResult {
513
584
  }
514
585
 
515
586
  if (!arg) {
516
- // Show current budgets
587
+ // Show current budgets. fmtTokens is exact below 1000 — flooring to "0k"
588
+ // hid real small values and made the display contradict the validator
589
+ // (which rejects 0 but accepts 50).
517
590
  const lines: Line[] = [{ text: '--- Stream Token Budgets ---', style: 'system' }];
518
591
  for (const agent of agents) {
519
592
  const budget = agent.maxStreamTokens;
520
593
  const last = agent.lastStreamInputTokens;
521
594
  const pct = budget > 0 ? ((last / budget) * 100).toFixed(0) : '—';
522
595
  lines.push({
523
- text: ` ${agent.name}: ${(budget / 1000).toFixed(0)}k (last: ${(last / 1000).toFixed(0)}k, ${pct}%)`,
596
+ text: ` ${agent.name}: ${fmtTokens(budget)} (last: ${fmtTokens(last)}, ${pct}%)`,
524
597
  style: 'system',
525
598
  });
526
599
  }
@@ -546,12 +619,8 @@ function handleBudget(framework: AgentFramework, arg?: string): CommandResult {
546
619
  agent.maxStreamTokens = tokens;
547
620
  }
548
621
 
549
- const display = tokens >= 1_000_000
550
- ? `${(tokens / 1_000_000).toFixed(1)}m`
551
- : `${(tokens / 1_000).toFixed(0)}k`;
552
-
553
622
  return {
554
- lines: [{ text: `Stream budget set to ${display} tokens for all agents.`, style: 'system' }],
623
+ lines: [{ text: `Stream budget set to ${fmtTokens(tokens)} tokens for all agents.`, style: 'system' }],
555
624
  };
556
625
  }
557
626
 
@@ -651,6 +720,89 @@ export function handleExport(app: AppContext): CommandResult {
651
720
  };
652
721
  }
653
722
 
723
+ /**
724
+ * /nudge [agent] — admin-level: queue an inference turn on the agent's
725
+ * CURRENT context without adding any message or event (framework
726
+ * `nudgeAgent`). The zero-pollution complement to /undo: rewind, then nudge,
727
+ * and the agent takes another swing at exactly what it already sees.
728
+ */
729
+ /**
730
+ * /puppet <toolName> [json-input] — admin: execute one tool AS the main
731
+ * agent and store the tool_use + tool_result pair in its window, exactly as
732
+ * a model-initiated call (Framework.puppetToolCall). The call runs for real.
733
+ * Refused unless the agent is idle and the tool is on its surface. Does not
734
+ * wake the agent. Born from the princess exemplar surgery (2026-08-23):
735
+ * one first-person pair restores a capacity the model can't find on its own
736
+ * — older models especially. Disclosure to the resident is the operator's
737
+ * call; the precedent was disclosed first.
738
+ */
739
+ function handlePuppet(app: AppContext, args: string[]): CommandResult {
740
+ const toolName = args[0];
741
+ if (!toolName) {
742
+ return {
743
+ lines: [
744
+ { text: 'Usage: /puppet <toolName> [json-input]', style: 'system' },
745
+ { text: ' Executes the tool AS the agent (for real) and stores the', style: 'system' },
746
+ { text: ' tool_use + tool_result pair in its window. Requires idle.', style: 'system' },
747
+ ],
748
+ };
749
+ }
750
+ const rawInput = args.slice(1).join(' ').trim();
751
+ let input: Record<string, unknown> = {};
752
+ if (rawInput) {
753
+ try {
754
+ const parsed = JSON.parse(rawInput);
755
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
756
+ return { lines: [{ text: 'puppet: input must be a JSON object', style: 'system' }] };
757
+ }
758
+ input = parsed;
759
+ } catch (e) {
760
+ return { lines: [{ text: `puppet: bad JSON input: ${e instanceof Error ? e.message : e}`, style: 'system' }] };
761
+ }
762
+ }
763
+ const agentName = app.agentName ?? app.framework.getAllAgents()[0]?.name;
764
+ if (!agentName) {
765
+ return { lines: [{ text: 'puppet: no registered agent', style: 'system' }] };
766
+ }
767
+ const asyncWork = (async (): Promise<CommandResult> => {
768
+ try {
769
+ const { toolUseId, result } = await app.framework.puppetToolCall(agentName, toolName, input);
770
+ const preview = result.isError
771
+ ? `ERROR: ${result.error ?? 'unknown'}`
772
+ : String(typeof result.data === 'string' ? result.data : JSON.stringify(result.data) ?? '').slice(0, 300);
773
+ return {
774
+ lines: [
775
+ { text: `puppet ${agentName}: ${toolName} → ${result.isError ? 'error' : 'ok'} (${toolUseId})`, style: 'system' },
776
+ { text: ` stored tool_use + tool_result in ${agentName}'s window (no wake).`, style: 'system' },
777
+ { text: ` result: ${preview.replace(/\n/g, ' ')}`, style: 'system' },
778
+ ],
779
+ };
780
+ } catch (e) {
781
+ return { lines: [{ text: `puppet failed: ${e instanceof Error ? e.message : e}`, style: 'system' }] };
782
+ }
783
+ })();
784
+ return {
785
+ lines: [{ text: `puppet: executing ${toolName} as ${agentName}...`, style: 'system' }],
786
+ asyncWork,
787
+ };
788
+ }
789
+
790
+ function handleNudge(app: AppContext, agentName?: string): CommandResult {
791
+ const r = app.framework.nudgeAgent(agentName, 'host-console');
792
+ if (!r.ok) {
793
+ return { lines: [{ text: `Nudge failed: ${r.error}`, style: 'system' }] };
794
+ }
795
+ const when = r.agentStatus === 'idle'
796
+ ? 'running now'
797
+ : `queued — runs when current turn settles (agent is ${r.agentStatus})`;
798
+ return {
799
+ lines: [{
800
+ text: `Nudged ${r.agentName}: inference on current context, no new events (${when}).`,
801
+ style: 'system',
802
+ }],
803
+ };
804
+ }
805
+
654
806
  function handleUndo(app: AppContext): CommandResult {
655
807
  const { framework, branchState: bs } = app;
656
808
  const cm = getAgentCM(framework);
@@ -742,7 +894,17 @@ function handleRedo(app: AppContext): CommandResult {
742
894
 
743
895
  function handleCheckpoint(app: AppContext, name?: string): CommandResult {
744
896
  if (!name) {
745
- return { lines: [{ text: 'Usage: /checkpoint <name>', style: 'system' }] };
897
+ // Bare /checkpoint lists what exists same affordance as bare /restore —
898
+ // so the lifecycle is discoverable from either end.
899
+ const names = [...app.branchState.checkpoints.keys()];
900
+ return {
901
+ lines: [
902
+ { text: 'Usage: /checkpoint <name>', style: 'system' },
903
+ ...(names.length > 0
904
+ ? [{ text: `Saved checkpoints: ${names.join(', ')}`, style: 'system' as const }]
905
+ : []),
906
+ ],
907
+ };
746
908
  }
747
909
 
748
910
  const cm = getAgentCM(app.framework);
@@ -838,8 +1000,8 @@ function handleRestore(app: AppContext, name?: string): CommandResult {
838
1000
  };
839
1001
  }
840
1002
 
841
- function handleBranches(framework: AgentFramework): CommandResult {
842
- const cm = getAgentCM(framework);
1003
+ function handleBranches(app: AppContext): CommandResult {
1004
+ const cm = getAgentCM(app.framework);
843
1005
  if (!cm) return { lines: [{ text: 'No agent context manager.', style: 'system' }] };
844
1006
 
845
1007
  const branches = cm.listBranches();
@@ -854,6 +1016,19 @@ function handleBranches(framework: AgentFramework): CommandResult {
854
1016
  });
855
1017
  }
856
1018
 
1019
+ // Checkpoints are positions (branch + message), not branches — but they're
1020
+ // part of the same mental model, and being invisible here made the whole
1021
+ // checkpoint lifecycle run blind: created → not listed anywhere → restored
1022
+ // on faith. Session-scoped, in-memory (cleared on session switch/restart).
1023
+ const cps = [...app.branchState.checkpoints.entries()];
1024
+ if (cps.length > 0) {
1025
+ lines.push({ text: `--- Checkpoints (${cps.length}, this session) ---`, style: 'system' });
1026
+ for (const [name, point] of cps) {
1027
+ const at = point.messageId ? ` @ [${point.messageId}]` : '';
1028
+ lines.push({ text: ` ${name} → ${point.branchName}${at}`, style: 'system' });
1029
+ }
1030
+ }
1031
+
857
1032
  return { lines };
858
1033
  }
859
1034
 
@@ -1041,13 +1216,27 @@ function handleMcpAdd(args: string[]): CommandResult {
1041
1216
 
1042
1217
  const [id, command, ...cmdArgs] = args;
1043
1218
  const servers = readMcplServersFile(DEFAULT_CONFIG_PATH);
1044
- const isOverwrite = id! in servers;
1045
- servers[id!] = { command: command!, ...(cmdArgs.length > 0 ? { args: cmdArgs } : {}) };
1219
+ const prev = servers[id!];
1220
+ // Overwrite replaces the command line (command + args, which travel as one
1221
+ // unit) but PRESERVES env and other settings: env is edited via /mcp env
1222
+ // and the panel editor, whose contract is "cleared only by explicit empty
1223
+ // save" — a command update silently dropping tokens/settings broke working
1224
+ // servers in a way that only surfaced at next start.
1225
+ servers[id!] = {
1226
+ ...prev,
1227
+ command: command!,
1228
+ ...(cmdArgs.length > 0 ? { args: cmdArgs } : {}),
1229
+ };
1230
+ if (cmdArgs.length === 0) delete servers[id!]!.args;
1046
1231
  saveMcplServers(DEFAULT_CONFIG_PATH, servers);
1047
1232
 
1233
+ const keptEnv = prev?.env ? Object.keys(prev.env) : [];
1048
1234
  return {
1049
1235
  lines: [
1050
- { text: `${isOverwrite ? 'Updated' : 'Added'} server "${id}". Restart to apply.`, style: 'system' },
1236
+ { text: `${prev ? 'Updated' : 'Added'} server "${id}". Restart to apply.`, style: 'system' },
1237
+ ...(keptEnv.length > 0
1238
+ ? [{ text: ` (kept env: ${keptEnv.join(', ')})`, style: 'system' as const }]
1239
+ : []),
1051
1240
  ],
1052
1241
  };
1053
1242
  }
@@ -113,5 +113,8 @@ export function buildFrameworkAgentConfig(
113
113
  ...(recipe.agent.proseRouting !== undefined
114
114
  ? { proseRouting: recipe.agent.proseRouting }
115
115
  : {}),
116
+ ...(recipe.agent.toolWrapperProseGuard !== undefined
117
+ ? { toolWrapperProseGuard: recipe.agent.toolWrapperProseGuard }
118
+ : {}),
116
119
  };
117
120
  }
@@ -2,6 +2,7 @@ import {
2
2
  AutobiographicalStrategy,
3
3
  PassthroughStrategy,
4
4
  type ContextStrategy,
5
+ type ConversationRouterConfig,
5
6
  } from '@animalabs/agent-framework';
6
7
  import type { Recipe, RecipeStrategy } from './recipe.js';
7
8
  import { FrontdeskStrategy } from './strategies/frontdesk-strategy.js';
@@ -12,11 +13,22 @@ const PASSTHROUGH_KEYS: ReadonlyArray<keyof RecipeStrategy> = [
12
13
  'maxSpeculativeL1s',
13
14
  'compressionRefusalCurveFallbacks',
14
15
  'compressionContextBudgetTokens',
16
+ 'compressionSourceOnly',
17
+ 'compressionSourceOnlyFallback',
18
+ 'compressionMergeSourceOnly',
19
+ 'compressionMergeSourceOnlyFallback',
20
+ 'compressionSplitFallback',
21
+ 'compressionSplitPlaceholder',
22
+ 'compressionSplitMaxCallsPerChunk',
23
+ 'compressionSplitMaxCallsPer10Min',
24
+ 'compressionRecallBudgetTokens',
15
25
  'positionedRecallPairs',
16
26
  'recallHeaderTemplate',
17
27
  'targetChunkTokens',
18
28
  'mergeThreshold',
29
+ 'mergeMaxSourceSpanMessages',
19
30
  'summaryTargetTokens',
31
+ 'productionBudgetTokens',
20
32
  'l1BudgetTokens',
21
33
  'l2BudgetTokens',
22
34
  'l3BudgetTokens',
@@ -28,6 +40,7 @@ const PASSTHROUGH_KEYS: ReadonlyArray<keyof RecipeStrategy> = [
28
40
  'compressionSlackRatio',
29
41
  'overBudgetGraceRatio',
30
42
  'foldingStrategy',
43
+ 'kvUnified',
31
44
  'speculativeProduction',
32
45
  'l1HoldbackChunks',
33
46
  'summaryParticipant',
@@ -124,3 +137,32 @@ export function buildFrameworkStrategy(
124
137
  ? new FrontdeskStrategy(autobiographicalOpts)
125
138
  : new AutobiographicalStrategy(autobiographicalOpts);
126
139
  }
140
+
141
+ /**
142
+ * Map a recipe's `conversations` block to the framework's
143
+ * ConversationRouterConfig. The host supplies the two fields a recipe
144
+ * cannot: `templateAgent` is the recipe's own (sole) agent, and
145
+ * `strategyFactory` builds a FRESH instance of the recipe's configured
146
+ * strategy per fork — strategy instances are stateful and must never be
147
+ * shared between ContextManagers (without a factory the framework would
148
+ * silently give forks passthrough, i.e. no compression).
149
+ */
150
+ export function buildConversationsConfig(
151
+ recipe: Recipe,
152
+ agentName: string,
153
+ model: string,
154
+ timeZone: string,
155
+ extensions?: ExtensionRegistry,
156
+ ): ConversationRouterConfig | undefined {
157
+ const conv = recipe.conversations;
158
+ if (!conv) return undefined;
159
+ return {
160
+ templateAgent: agentName,
161
+ ...(conv.bind !== undefined ? { bind: conv.bind } : {}),
162
+ ...(conv.trigger !== undefined ? { trigger: conv.trigger } : {}),
163
+ ...(conv.idleTtlMs !== undefined ? { idleTtlMs: conv.idleTtlMs } : {}),
164
+ ...(conv.closurePrompt !== undefined ? { closurePrompt: conv.closurePrompt } : {}),
165
+ ...(conv.agentPrefix !== undefined ? { agentPrefix: conv.agentPrefix } : {}),
166
+ strategyFactory: () => buildFrameworkStrategy(recipe, model, timeZone, extensions),
167
+ };
168
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Household-gateway telemetry headers (x-gate-* stamps).
3
+ *
4
+ * The data boundary these headers exist under: a household inference gateway
5
+ * records them into its ledger and STRIPS them before the vendor — the vendor
6
+ * must never see them. That boundary is only real if the host refuses to
7
+ * attach the stamps anywhere else, so attachment is double-gated:
8
+ *
9
+ * 1. `GATE_TELEMETRY=1` — the operator's explicit declaration that the
10
+ * configured base URL is such a gateway. Absent/false ⇒ never attach.
11
+ * 2. `ANTHROPIC_BASE_URL` actually set — the flag alone must not stamp
12
+ * traffic that would go to the vendor's default endpoint.
13
+ *
14
+ * Fail-closed on both (review finding on the first wiring: the stamp was
15
+ * attached unconditionally, so with no base URL configured the value went
16
+ * straight to the vendor).
17
+ *
18
+ * Two stamps ride the same hook:
19
+ *
20
+ * x-gate-debt-chunks compression debt at request build (every lane — the
21
+ * aux lane is where the debt series is most telling)
22
+ * x-gate-origin WHY the turn fired: heartbeat | event | mail |
23
+ * operator | <raw reason> — stream lane ONLY
24
+ * x-gate-channel where (adapter-namespaced id) — stream lane ONLY
25
+ * x-gate-counterparty who woke the agent (namespaced id) — stream lane ONLY
26
+ *
27
+ * The origin trio describes the agent's turn; a compression call running in
28
+ * the background is not the turn, so on the 'complete' lane those three are
29
+ * withheld (an older membrane that passes no lane gets them on every call —
30
+ * documented, and the ledger's `streamed` flag lets a reader tell the lanes
31
+ * apart regardless). Values are ids and short class words: never content,
32
+ * never display names.
33
+ */
34
+
35
+ /** Truthy env-flag parse: unset/''/'0'/'false' (any case) are off. */
36
+ function envFlag(value: string | undefined): boolean {
37
+ return value !== undefined && value !== '' && value !== '0' && value.toLowerCase() !== 'false';
38
+ }
39
+
40
+ /** What the framework knows about the turn in progress (agent-framework InferenceRequest). */
41
+ export interface TurnTrigger {
42
+ reason: string;
43
+ source: string;
44
+ /** Routing locus of the turn (direct channel wakes set it). */
45
+ channelId?: string;
46
+ /** Telemetry-only channel of a gate-batched wake (agent-framework ≥0.14:
47
+ * InferenceRequest.wakeChannelId) — never a locus. */
48
+ wakeChannelId?: string;
49
+ counterparty?: string;
50
+ }
51
+
52
+ export interface DynamicHeadersContext {
53
+ lane?: 'stream' | 'complete';
54
+ }
55
+
56
+ /**
57
+ * Collapse the framework's free-form reason/source pair into the ledger's
58
+ * origin classes. The raw reason survives when no class fits, clipped and
59
+ * sanitized (ids and words only), so a new event kind shows up as itself
60
+ * instead of vanishing into 'event'.
61
+ */
62
+ export function originClass(trigger: TurnTrigger): string {
63
+ const r = trigger.reason.toLowerCase();
64
+ const s = trigger.source.toLowerCase();
65
+ if (r.includes('heartbeat') || s.includes('heartbeat')) return 'heartbeat';
66
+ if (r.includes('mail') || s.includes('mail')) return 'mail';
67
+ // channel/push events, directly or batched through the framework's EventGate
68
+ if (r === 'mcpl:channel-incoming' || r === 'mcpl:push-event' || r.startsWith('discord') || r.startsWith('gate:') || s === 'gate') return 'event';
69
+ // a person typing at the host itself: headless IPC, CLI, TUI, web UI, API
70
+ if (r === 'external-message' || ['headless', 'cli', 'tui', 'webui', 'api'].includes(s)) return 'operator';
71
+ if (r.includes('admin') || r.includes('nudge') || r.includes('unstick') || r.includes('operator')) return 'operator';
72
+ return r.replace(/[^a-z0-9:_-]/g, '').slice(0, 40) || 'event';
73
+ }
74
+
75
+ /**
76
+ * Header-safe attribute. HTTP header values are ByteStrings: a single
77
+ * non-ASCII code point (an emoji in a channel name, say) makes Fetch throw
78
+ * and would turn telemetry into a failed model request. So the rule is
79
+ * fail-closed on the WHOLE value — visible ASCII (0x20..0x7e) only, clipped
80
+ * to 120 — never character-stripping, which would mint a different id and
81
+ * collide provenance. An unsendable id is simply not sent (null → dropped).
82
+ */
83
+ function attr(v: string | undefined): string | null {
84
+ if (typeof v !== 'string') return null;
85
+ const t = v.trim();
86
+ if (!t) return null;
87
+ for (let i = 0; i < t.length; i++) {
88
+ const code = t.charCodeAt(i);
89
+ if (code < 0x20 || code > 0x7e) return null;
90
+ }
91
+ return t.slice(0, 120);
92
+ }
93
+
94
+ /**
95
+ * Which agent's turn may be stamped onto a request, given that ONE provider
96
+ * adapter — and therefore one header hook — serves every agent in the
97
+ * process (primary, subconscious, forks, ephemerals) and the hook cannot
98
+ * tell whose request it is decorating. Rule: stamp the primary's trigger
99
+ * only while the primary is the ONLY agent with a turn in flight; any other
100
+ * agent mid-turn → withhold (null), never guess. Debt is per agent, not per
101
+ * request, so it may always be read from the primary.
102
+ */
103
+ export function stampedTrigger(view: {
104
+ agents: string[];
105
+ primary?: string | null;
106
+ triggerOf: (agent: string) => TurnTrigger | null | undefined;
107
+ }): TurnTrigger | null {
108
+ const primary = view.primary ?? (view.agents.length === 1 ? view.agents[0] : undefined);
109
+ if (!primary) return null;
110
+ for (const a of view.agents) {
111
+ if (a !== primary && view.triggerOf(a)) return null;
112
+ }
113
+ const t = view.triggerOf(primary);
114
+ return t ?? null;
115
+ }
116
+
117
+ export function gateTelemetryHeaders(
118
+ env: Record<string, string | undefined>,
119
+ pendingDebtChunks: () => number | null,
120
+ activeTrigger: () => TurnTrigger | null = () => null,
121
+ ): ((ctx?: DynamicHeadersContext) => Record<string, string | number | null>) | undefined {
122
+ if (!envFlag(env.GATE_TELEMETRY)) return undefined;
123
+ if (!env.ANTHROPIC_BASE_URL) return undefined;
124
+ return (ctx?: DynamicHeadersContext) => {
125
+ const out: Record<string, string | number | null> = { 'x-gate-debt-chunks': pendingDebtChunks() };
126
+ if (ctx?.lane === 'complete') return out;
127
+ const t = activeTrigger();
128
+ if (!t) return out;
129
+ out['x-gate-origin'] = originClass(t);
130
+ out['x-gate-channel'] = attr(t.channelId ?? t.wakeChannelId);
131
+ out['x-gate-counterparty'] = attr(t.counterparty);
132
+ return out;
133
+ };
134
+ }
package/src/headless.ts CHANGED
@@ -205,6 +205,16 @@ export async function runHeadless(app: AppContext, argv: string[] = []): Promise
205
205
  for (const line of result.lines) {
206
206
  emit({ type: 'command-output', text: line.text, style: line.style ?? null });
207
207
  }
208
+ // Commands with async follow-up (fleet kill/restart, puppet) put
209
+ // their real outcome in asyncWork; without this await the IPC
210
+ // caller only ever saw the "...starting" line and the result was
211
+ // silently dropped.
212
+ if (result.asyncWork) {
213
+ const followUp = await result.asyncWork;
214
+ for (const line of followUp.lines) {
215
+ emit({ type: 'command-output', text: line.text, style: line.style ?? null });
216
+ }
217
+ }
208
218
  if (result.switchToSessionId) {
209
219
  await app.switchSession(result.switchToSessionId);
210
220
  emit({ type: 'command-output', text: 'Session switched.', style: 'system' });