@animalabs/connectome-host 0.8.0 → 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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,81 @@ release time — see [CONTRIBUTING.md](CONTRIBUTING.md#changelog).
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.8.1 — 2026-09-10
10
+
11
+ ### Added
12
+
13
+ - Recipe `subconscious` block (tune-out, agent-framework#77): `enabled`,
14
+ `systemPrompt` (required — the subconscious's mode block), optional `name`,
15
+ `model`, `allowChannelSpeech`, `reAnchorFraction`. Validated at recipe load
16
+ (unknown fields refused by name) and passed through verbatim to
17
+ `FrameworkConfig.subconscious`; the framework owns the defaults. Requires
18
+ agent-framework with tune-out (#115).
19
+
20
+ - Add validated recipe plumbing for Agent Framework's default-off `agent.toolWrapperProseGuard` containment boundary.
21
+
22
+ ### Changed
23
+
24
+ - Gate telemetry reads `x-gate-channel` from the turn's routing locus or,
25
+ for gate-batched wakes that set none, from the framework's telemetry-only
26
+ `wakeChannelId` (agent-framework ≥0.14). On residents with a subconscious
27
+ the stamp now follows the primary (the exactly-one-agent guard had
28
+ silenced it), and — because one provider adapter serves every agent — the
29
+ origin trio is stamped only while the primary is the only agent with a
30
+ turn in flight; overlap withholds rather than misattributes. Compression
31
+ debt is read from the primary as well.
32
+
33
+ ### Fixed
34
+
35
+ - Recipes' `agent.strategy.mergeMaxSourceSpanMessages` now reaches the Context
36
+ Manager (it was accepted but never passed through, so the CM default applied
37
+ regardless of the recipe). Also plumbs and validates the Context Manager's
38
+ `compressionSplitFallback`, `compressionSplitPlaceholder`,
39
+ `compressionSplitMaxCallsPerChunk` and `compressionSplitMaxCallsPer10Min`
40
+ keys (all default off / CM defaults).
41
+
42
+ - Gate telemetry classifies EventGate-batched wakes (`gate:debounce`, source
43
+ `gate`) as origin `event` instead of passing the raw reason through.
44
+
45
+ - Name-taking commands (`/checkpoint`, `/restore`, `/checkout`,
46
+ `/session switch`, `/session delete`) parse the rest of the line instead of
47
+ only the first token, so multi-word names round-trip with
48
+ `/session rename` instead of silently truncating (`/checkpoint my test
49
+ point` used to save a checkpoint named `my`).
50
+ - `/session delete` requires `--confirm`: the bare command echoes exactly
51
+ which session matched (name, id, message count) before anything
52
+ irreversible happens. `/help` documents that switch/delete accept ids.
53
+ - Head-moving commands (`/undo`, `/redo`, `/checkout`, `/restore`,
54
+ `/branchto`, `/newtopic`) are refused while a turn is in flight — moving
55
+ the head mid-stream committed the streaming reply onto the wrong branch,
56
+ detached from its request (orphaned Chronicle nodes), including when the
57
+ move came from a second client on the same session.
58
+ - `/mcp add` on an existing server preserves its env vars and `toolPrefix`
59
+ (and reports the kept env keys); previously a command update silently
60
+ wiped the server's env, which only surfaced when the server next started
61
+ without its tokens.
62
+ - Checkpoints are visible: `/branches` lists them alongside branches, and
63
+ bare `/checkpoint` lists existing checkpoints (matching bare `/restore`).
64
+ - `/budget` displays small values exactly instead of flooring to `0k`
65
+ (`/budget 50` used to report "set to 0k" while rejecting `/budget 0`).
66
+ - `/clear` clears the WebUI transcript view (client-side, like the TUI's
67
+ scrollback wipe) instead of appending a "(cleared)" line while clearing
68
+ nothing; `/help` and the headless reply now say what `/clear` actually
69
+ does — display only, history and context kept.
70
+
71
+ - The WebUI HTTP surface answers honestly: unknown `/debug/*` paths (typos,
72
+ casing, trailing slashes) return a JSON 404 instead of the SPA shell with
73
+ a 200; missing `/assets/*` files return 404 instead of HTML (which
74
+ produced a blank page with a MIME error on stale bundle hashes); non-GET
75
+ methods get 405 with an `Allow` header. SPA client-side routes still fall
76
+ back to the shell.
77
+ - The context-makeup panel's exact token count calls `count_tokens` with the
78
+ model the agent actually runs (provider/Bedrock prefixes normalized away)
79
+ instead of a hardcoded id that 404'd on every install and silently nulled
80
+ `exactTotalTokens`. `COUNT_TOKENS_MODEL` remains as an explicit override;
81
+ non-Anthropic models report `count_tokens_unsupported_model` instead of
82
+ counting against the wrong tokenizer.
83
+
9
84
  ## 0.8.0 — 2026-09-05
10
85
 
11
86
  ### Added
package/bun.lock CHANGED
@@ -5,9 +5,9 @@
5
5
  "": {
6
6
  "name": "@animalabs/connectome-host",
7
7
  "dependencies": {
8
- "@animalabs/agent-framework": "^0.12.0",
8
+ "@animalabs/agent-framework": "^0.13.0",
9
9
  "@animalabs/chronicle": "^0.3.0",
10
- "@animalabs/context-manager": "^0.6.3",
10
+ "@animalabs/context-manager": "^0.8.0",
11
11
  "@animalabs/membrane": "^0.5.82",
12
12
  "@opentui/core": "^0.1.82",
13
13
  },
@@ -19,11 +19,11 @@
19
19
  },
20
20
  },
21
21
  "packages": {
22
- "@animalabs/agent-framework": ["@animalabs/agent-framework@0.12.0", "", { "dependencies": { "@animalabs/chronicle": "^0.3.0", "@animalabs/context-manager": "^0.6.0", "@animalabs/membrane": "^0.5.78", "chokidar": "^4.0.3", "discord.js": "^14.25.1", "ws": "^8.18.0" }, "bin": { "agent-framework-mcp": "dist/src/api/mcp-server.js", "agent-framework-recover": "dist/src/recovery/recover-cli.js" } }, "sha512-Yj0E4vA+kqMxp8LzVT1DBBppwBtPaaweOCloO47pAbJemLzLsJoCD1O9GOYjZ75LLfvGenzLgg1fWo0wXkMJ1w=="],
22
+ "@animalabs/agent-framework": ["@animalabs/agent-framework@0.13.0", "", { "dependencies": { "@animalabs/chronicle": "^0.3.0", "@animalabs/context-manager": "^0.8.0", "@animalabs/membrane": "^0.5.78", "chokidar": "^4.0.3", "discord.js": "^14.25.1", "ws": "^8.18.0" }, "bin": { "agent-framework-mcp": "dist/src/api/mcp-server.js", "agent-framework-recover": "dist/src/recovery/recover-cli.js" } }, "sha512-j4QY9pEjOHdmqnwyUPp4NU6fW1f5EHb3Lp+ygoAB3LKj2QjLITlglNFNuLqjRWtGiwwamSopcuqgbwHwAp0wyQ=="],
23
23
 
24
24
  "@animalabs/chronicle": ["@animalabs/chronicle@0.3.0", "", {}, "sha512-6BBJ9pWb8AnuHTNJTU07Y6Wut3IvdgWFxsy05IdlWcEnR5W23dlaBlaeJq7gDc7TwX05HfsDC+QZFEc69G+s4Q=="],
25
25
 
26
- "@animalabs/context-manager": ["@animalabs/context-manager@0.6.3", "", { "dependencies": { "@animalabs/chronicle": "^0.3.0", "@animalabs/membrane": "^0.5.69" } }, "sha512-zh60LgZxXzBbGrLpDHBAfGSPSL/H8mxz8nSWLOSh4pjiTOYbD0o0BjAM8CFz4LiaOIXnuSCbRoiv5UKsnrKwDw=="],
26
+ "@animalabs/context-manager": ["@animalabs/context-manager@0.8.0", "", { "dependencies": { "@animalabs/chronicle": "^0.3.0", "@animalabs/membrane": "^0.5.69" } }, "sha512-lMYU+FazgSl6FFNig8N8Zjo6vqxn16j6H5GkYldS3R902nnUX4gjsqhup/yQDqrrOJuwSvpkrQBpnV8q2iOykA=="],
27
27
 
28
28
  "@animalabs/membrane": ["@animalabs/membrane@0.5.82", "", { "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } }, "sha512-kCI3s/HXnXBPVcYCXIZfsdJBLRj77j5QyA9Hor1mC/nyD35D7e7N/VgR9awxqdGGWgIvgpjs6owXbGMk5t1pQA=="],
29
29
 
@@ -275,8 +275,6 @@
275
275
 
276
276
  "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
277
277
 
278
- "@animalabs/context-manager/@animalabs/membrane": ["@animalabs/membrane@0.5.80", "", { "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } }, "sha512-D4xEij+J/3sJo2ZKFyjAeb0VuJl3QmygzEmi44J0nDNtmJSpUPYMJMaSid/ABj3dIs5EyXds/Ltr4qqyQ2DRTw=="],
279
-
280
278
  "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
281
279
 
282
280
  "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/connectome-host",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "General-purpose agent TUI host with recipe-based configuration",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -14,9 +14,9 @@
14
14
  "postinstall": "test -d web && npm --prefix web install && npm --prefix web run build || true"
15
15
  },
16
16
  "dependencies": {
17
- "@animalabs/agent-framework": "^0.12.0",
17
+ "@animalabs/agent-framework": "^0.13.0",
18
18
  "@animalabs/chronicle": "^0.3.0",
19
- "@animalabs/context-manager": "^0.6.3",
19
+ "@animalabs/context-manager": "^0.8.0",
20
20
  "@animalabs/membrane": "^0.5.82",
21
21
  "@opentui/core": "^0.1.82"
22
22
  },
package/src/commands.ts CHANGED
@@ -121,6 +121,35 @@ function getAgentCM(framework: AgentFramework, agentName?: string): ContextManag
121
121
  return all[0]?.getContextManager() ?? null;
122
122
  }
123
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
+
124
153
  export function handleCommand(command: string, app: AppContext): CommandResult {
125
154
  const parts = command.slice(1).split(/\s+/);
126
155
  const cmd = parts[0]!;
@@ -147,16 +176,16 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
147
176
  { text: '--- Commands ---', style: 'system' },
148
177
  { text: ' /quit, /q Exit the app', style: 'system' },
149
178
  { text: ' /status Show agent status', style: 'system' },
150
- { text: ' /clear Clear conversation', style: 'system' },
179
+ { text: ' /clear Clear this client\'s display (history/context are kept)', style: 'system' },
151
180
  { text: ' /lessons Show lesson library', style: 'system' },
152
181
  { text: ' /export Export lessons to ./output/ (JSON + markdown)', style: 'system' },
153
182
  { text: ' /undo Revert last agent turn', style: 'system' },
154
183
  { text: ' /redo Re-apply undone action', style: 'system' },
155
184
  { text: ' /nudge [agent] Run inference on current context (no new events)', style: 'system' },
156
185
  { text: ' /puppet <tool> [json] Admin: execute a tool AS the agent, store the pair', style: 'system' },
157
- { text: ' /checkpoint <name> Save current state', style: 'system' },
158
- { text: ' /restore <name> Restore to checkpoint', style: 'system' },
159
- { text: ' /branches List Chronicle branches', 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' },
160
189
  { text: ' /checkout <name> Switch to branch', style: 'system' },
161
190
  { text: ' /history [n] Show state transitions (last n)', style: 'system' },
162
191
  { text: ' /find <text> Search messages for text', style: 'system' },
@@ -170,9 +199,9 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
170
199
  { text: ' /session Show current session', style: 'system' },
171
200
  { text: ' /session list List all sessions', style: 'system' },
172
201
  { text: ' /session new [name] Create new session', style: 'system' },
173
- { text: ' /session switch <name> Switch to session', style: 'system' },
202
+ { text: ' /session switch <name or id> Switch to session', style: 'system' },
174
203
  { text: ' /session rename <name> Rename current session', style: 'system' },
175
- { text: ' /session delete <name> Delete a session', style: 'system' },
204
+ { text: ' /session delete <name or id> --confirm Delete a session (irreversible)', style: 'system' },
176
205
  { text: ' /recipe Show current recipe info', style: 'system' },
177
206
  { text: ' /newtopic [context] Reset head window (auto-summarize if empty)', style: 'system' },
178
207
  { text: ' /usage Show session token usage and costs', style: 'system' },
@@ -182,7 +211,12 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
182
211
  };
183
212
 
184
213
  case 'clear':
185
- 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' }] };
186
220
 
187
221
  case 'status':
188
222
  return handleStatus(framework);
@@ -194,7 +228,7 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
194
228
  return handleExport(app);
195
229
 
196
230
  case 'undo':
197
- return handleUndo(app);
231
+ return inFlightGuard(app, cmd) ?? handleUndo(app);
198
232
 
199
233
  case 'nudge':
200
234
  return handleNudge(app, args[0]);
@@ -203,25 +237,29 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
203
237
  return handlePuppet(app, args);
204
238
 
205
239
  case 'redo':
206
- return handleRedo(app);
240
+ return inFlightGuard(app, cmd) ?? handleRedo(app);
207
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.
208
246
  case 'checkpoint':
209
- return handleCheckpoint(app, args[0]);
247
+ return handleCheckpoint(app, args.join(' ') || undefined);
210
248
 
211
249
  case 'restore':
212
- return handleRestore(app, args[0]);
250
+ return inFlightGuard(app, cmd) ?? handleRestore(app, args.join(' ') || undefined);
213
251
 
214
252
  case 'branches':
215
- return handleBranches(framework);
253
+ return handleBranches(app);
216
254
 
217
255
  case 'checkout':
218
- return handleCheckout(framework, args[0]);
256
+ return inFlightGuard(app, cmd) ?? handleCheckout(framework, args.join(' ') || undefined);
219
257
 
220
258
  case 'history':
221
259
  return handleHistory(framework, args[0]);
222
260
 
223
261
  case 'branchto':
224
- return handleBranchTo(app, args[0]);
262
+ return inFlightGuard(app, cmd) ?? handleBranchTo(app, args[0]);
225
263
 
226
264
  case 'find':
227
265
  return handleFind(framework, args.join(' '));
@@ -242,7 +280,7 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
242
280
  return handleRecipe(app);
243
281
 
244
282
  case 'newtopic':
245
- return handleNewTopic(app, args);
283
+ return inFlightGuard(app, cmd) ?? handleNewTopic(app, args);
246
284
 
247
285
  case 'usage':
248
286
  return handleUsage(app);
@@ -310,12 +348,14 @@ function handleSession(app: AppContext, args: string[]): CommandResult {
310
348
  return handleSessionNew(app, args.slice(1).join(' ') || undefined);
311
349
  case 'switch':
312
350
  case 'sw':
313
- 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);
314
354
  case 'rename':
315
355
  return handleSessionRename(app, args.slice(1).join(' ') || undefined);
316
356
  case 'delete':
317
357
  case 'rm':
318
- return handleSessionDelete(app, args[1]);
358
+ return handleSessionDelete(app, args.slice(1));
319
359
  default:
320
360
  return { lines: [{ text: `Unknown /session subcommand: ${sub}. Try /session list.`, style: 'system' }] };
321
361
  }
@@ -411,9 +451,17 @@ function handleSessionRename(app: AppContext, name?: string): CommandResult {
411
451
  return { lines: [{ text: `Session renamed to "${name}".`, style: 'system' }] };
412
452
  }
413
453
 
414
- 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
+
415
463
  if (!nameOrId) {
416
- return { lines: [{ text: 'Usage: /session delete <name or id>', style: 'system' }] };
464
+ return { lines: [{ text: 'Usage: /session delete <name or id> [--confirm]', style: 'system' }] };
417
465
  }
418
466
 
419
467
  const session = app.sessionManager.findSession(nameOrId);
@@ -421,6 +469,16 @@ function handleSessionDelete(app: AppContext, nameOrId?: string): CommandResult
421
469
  return { lines: [{ text: `Session "${nameOrId}" not found.`, style: 'system' }] };
422
470
  }
423
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
+
424
482
  try {
425
483
  app.sessionManager.deleteSession(session.id);
426
484
  return { lines: [{ text: `Deleted session "${session.name}" [${session.id}].`, style: 'system' }] };
@@ -526,14 +584,16 @@ function handleBudget(framework: AgentFramework, arg?: string): CommandResult {
526
584
  }
527
585
 
528
586
  if (!arg) {
529
- // 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).
530
590
  const lines: Line[] = [{ text: '--- Stream Token Budgets ---', style: 'system' }];
531
591
  for (const agent of agents) {
532
592
  const budget = agent.maxStreamTokens;
533
593
  const last = agent.lastStreamInputTokens;
534
594
  const pct = budget > 0 ? ((last / budget) * 100).toFixed(0) : '—';
535
595
  lines.push({
536
- 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}%)`,
537
597
  style: 'system',
538
598
  });
539
599
  }
@@ -559,12 +619,8 @@ function handleBudget(framework: AgentFramework, arg?: string): CommandResult {
559
619
  agent.maxStreamTokens = tokens;
560
620
  }
561
621
 
562
- const display = tokens >= 1_000_000
563
- ? `${(tokens / 1_000_000).toFixed(1)}m`
564
- : `${(tokens / 1_000).toFixed(0)}k`;
565
-
566
622
  return {
567
- 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' }],
568
624
  };
569
625
  }
570
626
 
@@ -838,7 +894,17 @@ function handleRedo(app: AppContext): CommandResult {
838
894
 
839
895
  function handleCheckpoint(app: AppContext, name?: string): CommandResult {
840
896
  if (!name) {
841
- 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
+ };
842
908
  }
843
909
 
844
910
  const cm = getAgentCM(app.framework);
@@ -934,8 +1000,8 @@ function handleRestore(app: AppContext, name?: string): CommandResult {
934
1000
  };
935
1001
  }
936
1002
 
937
- function handleBranches(framework: AgentFramework): CommandResult {
938
- const cm = getAgentCM(framework);
1003
+ function handleBranches(app: AppContext): CommandResult {
1004
+ const cm = getAgentCM(app.framework);
939
1005
  if (!cm) return { lines: [{ text: 'No agent context manager.', style: 'system' }] };
940
1006
 
941
1007
  const branches = cm.listBranches();
@@ -950,6 +1016,19 @@ function handleBranches(framework: AgentFramework): CommandResult {
950
1016
  });
951
1017
  }
952
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
+
953
1032
  return { lines };
954
1033
  }
955
1034
 
@@ -1137,13 +1216,27 @@ function handleMcpAdd(args: string[]): CommandResult {
1137
1216
 
1138
1217
  const [id, command, ...cmdArgs] = args;
1139
1218
  const servers = readMcplServersFile(DEFAULT_CONFIG_PATH);
1140
- const isOverwrite = id! in servers;
1141
- 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;
1142
1231
  saveMcplServers(DEFAULT_CONFIG_PATH, servers);
1143
1232
 
1233
+ const keptEnv = prev?.env ? Object.keys(prev.env) : [];
1144
1234
  return {
1145
1235
  lines: [
1146
- { 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
+ : []),
1147
1240
  ],
1148
1241
  };
1149
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
  }
@@ -17,11 +17,16 @@ const PASSTHROUGH_KEYS: ReadonlyArray<keyof RecipeStrategy> = [
17
17
  'compressionSourceOnlyFallback',
18
18
  'compressionMergeSourceOnly',
19
19
  'compressionMergeSourceOnlyFallback',
20
+ 'compressionSplitFallback',
21
+ 'compressionSplitPlaceholder',
22
+ 'compressionSplitMaxCallsPerChunk',
23
+ 'compressionSplitMaxCallsPer10Min',
20
24
  'compressionRecallBudgetTokens',
21
25
  'positionedRecallPairs',
22
26
  'recallHeaderTemplate',
23
27
  'targetChunkTokens',
24
28
  'mergeThreshold',
29
+ 'mergeMaxSourceSpanMessages',
25
30
  'summaryTargetTokens',
26
31
  'productionBudgetTokens',
27
32
  'l1BudgetTokens',
@@ -41,7 +41,11 @@ function envFlag(value: string | undefined): boolean {
41
41
  export interface TurnTrigger {
42
42
  reason: string;
43
43
  source: string;
44
+ /** Routing locus of the turn (direct channel wakes set it). */
44
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;
45
49
  counterparty?: string;
46
50
  }
47
51
 
@@ -60,7 +64,8 @@ export function originClass(trigger: TurnTrigger): string {
60
64
  const s = trigger.source.toLowerCase();
61
65
  if (r.includes('heartbeat') || s.includes('heartbeat')) return 'heartbeat';
62
66
  if (r.includes('mail') || s.includes('mail')) return 'mail';
63
- if (r === 'mcpl:channel-incoming' || r === 'mcpl:push-event' || r.startsWith('discord')) return 'event';
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';
64
69
  // a person typing at the host itself: headless IPC, CLI, TUI, web UI, API
65
70
  if (r === 'external-message' || ['headless', 'cli', 'tui', 'webui', 'api'].includes(s)) return 'operator';
66
71
  if (r.includes('admin') || r.includes('nudge') || r.includes('unstick') || r.includes('operator')) return 'operator';
@@ -86,6 +91,29 @@ function attr(v: string | undefined): string | null {
86
91
  return t.slice(0, 120);
87
92
  }
88
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
+
89
117
  export function gateTelemetryHeaders(
90
118
  env: Record<string, string | undefined>,
91
119
  pendingDebtChunks: () => number | null,
@@ -99,7 +127,7 @@ export function gateTelemetryHeaders(
99
127
  const t = activeTrigger();
100
128
  if (!t) return out;
101
129
  out['x-gate-origin'] = originClass(t);
102
- out['x-gate-channel'] = attr(t.channelId);
130
+ out['x-gate-channel'] = attr(t.channelId ?? t.wakeChannelId);
103
131
  out['x-gate-counterparty'] = attr(t.counterparty);
104
132
  return out;
105
133
  };
package/src/index.ts CHANGED
@@ -28,7 +28,7 @@ import {
28
28
  } from '@animalabs/membrane';
29
29
  import { LoggingAnthropicAdapter } from './logging-adapter.js';
30
30
  import { LoggingProviderAdapter } from './logging-provider-wrapper.js';
31
- import { gateTelemetryHeaders, type TurnTrigger } from './gate-telemetry.js';
31
+ import { gateTelemetryHeaders, stampedTrigger, type TurnTrigger } from './gate-telemetry.js';
32
32
  import { LoggingBedrockAdapter } from './logging-bedrock-adapter.js';
33
33
  import { CodexSubscriptionAdapter } from './codex-subscription-adapter.js';
34
34
  import { CallLedger } from './call-ledger.js';
@@ -510,6 +510,9 @@ agents: [agentConfig],
510
510
  // Client-side programmatic tool calling (code_execution) — recipe opt-in.
511
511
  ...(recipe.codeExecution ? { codeExecution: recipe.codeExecution } : {}),
512
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 } : {}),
513
516
  });
514
517
 
515
518
  // Wire post-creation hooks
@@ -946,11 +949,22 @@ async function main() {
946
949
  // multi-agent process (whose debt would we even claim?), no strategy — the
947
950
  // header is simply not sent: an unstamped call is honest, a guessed one lies.
948
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
+ };
949
963
  const pendingDebtChunks = (): number | null => {
950
964
  try {
951
- const agents = appRefForDebt?.framework.getAllAgents() ?? [];
952
- if (agents.length !== 1) return null;
953
- const strategy = (agents[0] as unknown as {
965
+ const p = primaryAgent();
966
+ if (!p) return null;
967
+ const strategy = (p.agent as unknown as {
954
968
  getContextManager?: () => { getStrategy?: () => { getCompressionDebt?: () => unknown } };
955
969
  }).getContextManager?.()?.getStrategy?.();
956
970
  const d = strategy?.getCompressionDebt?.() as { pendingChunks?: unknown } | undefined;
@@ -967,10 +981,23 @@ async function main() {
967
981
  // -> null -> the origin trio is simply not sent.
968
982
  const activeTurnTrigger = (): TurnTrigger | null => {
969
983
  try {
970
- const agents = appRefForDebt?.framework.getAllAgents() ?? [];
971
- if (agents.length !== 1) return null;
972
- const t = appRefForDebt?.framework.getActiveTurnTrigger(agents[0]!.name);
973
- return t ? { reason: t.reason, source: t.source, channelId: t.channelId, counterparty: t.counterparty } : null;
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;
974
1001
  } catch {
975
1002
  return null;
976
1003
  }