@animalabs/connectome-host 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,7 +1,54 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.6.0 — 2026-07-26
6
+
7
+ ### Added
8
+
9
+ - **Pins panel** — operator control over protected ranges, using the pin surface
10
+ that already existed in context-manager (`pinRange` / `markDocument` / `unpin`
11
+ / `listPins`). No cm or af change was needed.
12
+ - Three semantics kept visibly distinct rather than collapsed into one "pin",
13
+ because they do different things to the fold plan: **raw** (never folded),
14
+ **max L<sub>k</sub>** (fold no deeper than k; k=0 ≡ raw), and **at
15
+ L<sub>k</sub>** (pinned at exactly k — the frontier cut passes through that
16
+ node).
17
+ - `at L_k` is honored **only** by `foldingStrategy: 'kv-stable'`; elsewhere it
18
+ degrades to raw. The panel detects this and warns, rather than letting a
19
+ request silently mean something else.
20
+ - Ids are pickable from `/debug/context/curve` (~14ms, the cheapest debug
21
+ endpoint and the only one exposing per-entry store ids with a text preview),
22
+ so ranges are selected from the live context instead of pasted by hand.
23
+ Entries without a store id — merged summaries — are omitted, since a pin
24
+ needs a message id.
25
+ - New `request-pins` / `pin-add` / `pin-remove`, server frame `pins-list`,
26
+ broadcast on change like `settings-state`: pins alter what the next compile
27
+ folds, so operators must not hold divergent views. Full-auth only via
28
+ `observerMaySend`'s default-deny. `level` and `maxLevel` together is rejected
29
+ at the wire as ambiguous.
30
+ - Pins take effect on the next compile — no restart — and pair with dry run,
31
+ which is now ~1.6s rather than minutes.
32
+
33
+ ### Fixed
34
+
35
+ - Dry-run cost text said "~8s"; measured ~1.6s after the context-manager solver
36
+ fixes. Corrected rather than left pessimistic.
37
+
3
38
  ## 0.5.4 — 2026-07-26
4
39
 
40
+ ### Corrected after release
41
+
42
+ - The `0.5.4` note below claimed the entry projection fixed the 110s stall. **It
43
+ did not.** Re-measuring after the change showed 121,855ms — unchanged. The
44
+ projection cut payload (megabytes → 265KB) and removed the blob-inlining heap
45
+ exposure, both worth keeping, but the time was never serialization. The actual
46
+ cause was an O(members × groupSize) cliff in three kv-control solver loops,
47
+ triggered whenever a head/tail boundary falls inside a deep summary group —
48
+ fixed in context-manager (`1c4c436`, `7f2d5e1`; see
49
+ `docs/incremental-compile-problem.md` §9.3). Measured after that fix: dry run
50
+ **1.56s**, dry run + render **1.69s**, live compile 22.5s → 2.4s.
51
+
5
52
  ### Fixed
6
53
 
7
54
  - **`dry run + show context` was a 110-second agent stall.** Measured 110,348ms
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/connectome-host",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "description": "General-purpose agent TUI host with recipe-based configuration",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/index.ts CHANGED
@@ -448,6 +448,8 @@ agents: [agentConfig],
448
448
  mcplServers: finalServers,
449
449
  gate: gateOptions,
450
450
  timeZone,
451
+ // Client-side programmatic tool calling (code_execution) — recipe opt-in.
452
+ ...(recipe.codeExecution ? { codeExecution: recipe.codeExecution } : {}),
451
453
  });
452
454
 
453
455
  // Wire post-creation hooks
@@ -59,6 +59,7 @@ import {
59
59
  type CallLedgerSnapshot,
60
60
  type McplListMessage,
61
61
  type SettingsStateMessage,
62
+ type PinsListMessage,
62
63
  type BranchesListMessage,
63
64
  type LessonsListMessage,
64
65
  } from '../web/protocol.js';
@@ -1977,6 +1978,64 @@ export class WebUiModule implements Module {
1977
1978
  return;
1978
1979
  }
1979
1980
 
1981
+ case 'request-pins': {
1982
+ const msg = this.buildPinsList(this.resolveSettingsAgent(parsed.agent));
1983
+ if (!msg) {
1984
+ this.send(client, { type: 'error', message: 'pins unavailable on this build' });
1985
+ return;
1986
+ }
1987
+ this.send(client, msg);
1988
+ return;
1989
+ }
1990
+
1991
+ // Pins change what the NEXT compile folds, so like settings these
1992
+ // broadcast rather than replying to the requester only.
1993
+ case 'pin-add': {
1994
+ const agentName = this.resolveSettingsAgent(parsed.agent);
1995
+ try {
1996
+ const cm = this.pinnableCm(agentName);
1997
+ const opts: Record<string, unknown> = {};
1998
+ if (parsed.name !== undefined) opts.name = parsed.name;
1999
+ if (parsed.level !== undefined) opts.level = parsed.level;
2000
+ if (parsed.maxLevel !== undefined) opts.maxLevel = parsed.maxLevel;
2001
+ if (parsed.kind === 'document') {
2002
+ cm.markDocument!(parsed.firstMessageId, opts);
2003
+ } else {
2004
+ // A single-message pin is a range of one; the strategy takes both ends.
2005
+ cm.pinRange!(parsed.firstMessageId, parsed.lastMessageId ?? parsed.firstMessageId, opts);
2006
+ }
2007
+ } catch (err) {
2008
+ this.send(client, {
2009
+ type: 'error',
2010
+ message: `pin-add failed: ${err instanceof Error ? err.message : String(err)}`,
2011
+ });
2012
+ return;
2013
+ }
2014
+ this.broadcastPinsList(agentName);
2015
+ return;
2016
+ }
2017
+
2018
+ case 'pin-remove': {
2019
+ const agentName = this.resolveSettingsAgent(parsed.agent);
2020
+ try {
2021
+ const cm = this.pinnableCm(agentName);
2022
+ const ok = cm.unpin!(parsed.pinId);
2023
+ if (!ok) {
2024
+ // Not an exception: a stale panel can ask twice. Say so plainly and
2025
+ // still re-broadcast, so the client converges on reality.
2026
+ this.send(client, { type: 'error', message: `no such pin: ${parsed.pinId}` });
2027
+ }
2028
+ } catch (err) {
2029
+ this.send(client, {
2030
+ type: 'error',
2031
+ message: `pin-remove failed: ${err instanceof Error ? err.message : String(err)}`,
2032
+ });
2033
+ return;
2034
+ }
2035
+ this.broadcastPinsList(agentName);
2036
+ return;
2037
+ }
2038
+
1980
2039
  case 'request-settings': {
1981
2040
  this.sendSettingsState(client, parsed.agent);
1982
2041
  return;
@@ -1988,10 +2047,11 @@ export class WebUiModule implements Module {
1988
2047
  case 'settings-update': {
1989
2048
  const agentName = this.resolveSettingsAgent(parsed.agent);
1990
2049
  try {
1991
- const patch: Record<string, number> = {};
2050
+ const patch: Record<string, number | boolean> = {};
1992
2051
  if (parsed.contextBudgetTokens !== undefined) patch.contextBudgetTokens = parsed.contextBudgetTokens;
1993
2052
  if (parsed.tailTokens !== undefined) patch.tailTokens = parsed.tailTokens;
1994
2053
  if (parsed.transitionPaceTokens !== undefined) patch.transitionPaceTokens = parsed.transitionPaceTokens;
2054
+ if (parsed.immediate !== undefined) patch.immediate = parsed.immediate;
1995
2055
  const fw = sharedServer!.app.framework as unknown as {
1996
2056
  updateAgentRuntimeSettings: (n: string, p: unknown, o?: { persist?: boolean }) => unknown;
1997
2057
  };
@@ -2712,6 +2772,98 @@ export class WebUiModule implements Module {
2712
2772
  }
2713
2773
  }
2714
2774
 
2775
+ /** Duck-typed pin surface. Throws with a clear reason rather than returning a
2776
+ * half-usable object, so handlers can report it to the operator verbatim. */
2777
+ private pinnableCm(agentName: string): {
2778
+ pinRange?: (a: string, b: string, o?: unknown) => string;
2779
+ markDocument?: (a: string, o?: unknown) => string;
2780
+ unpin?: (id: string) => boolean;
2781
+ listPins?: () => ReadonlyArray<Record<string, unknown>>;
2782
+ } {
2783
+ const app = sharedServer?.app;
2784
+ if (!app) throw new Error('app not bound yet');
2785
+ const agent = app.framework.getAgent(agentName);
2786
+ if (!agent) throw new Error(`Unknown agent: ${agentName}`);
2787
+ const cm = agent.getContextManager() as unknown as {
2788
+ pinRange?: (a: string, b: string, o?: unknown) => string;
2789
+ markDocument?: (a: string, o?: unknown) => string;
2790
+ unpin?: (id: string) => boolean;
2791
+ listPins?: () => ReadonlyArray<Record<string, unknown>>;
2792
+ };
2793
+ if (typeof cm.listPins !== 'function' || typeof cm.pinRange !== 'function') {
2794
+ throw new Error('the active context strategy does not support pins');
2795
+ }
2796
+ return cm;
2797
+ }
2798
+
2799
+ /**
2800
+ * Pin snapshot. Never throws — an unsupported strategy is a legitimate
2801
+ * read-only state for the panel, not an error to surface.
2802
+ *
2803
+ * `levelHonored` matters: pin-AT-level is implemented only by the kv-stable
2804
+ * controller. Elsewhere it degrades to raw, which is a safe superset but not
2805
+ * what the operator asked for, so the UI needs to be able to say so.
2806
+ */
2807
+ private buildPinsList(agentName: string): PinsListMessage | null {
2808
+ const app = sharedServer?.app;
2809
+ if (!app) return null;
2810
+ let pins: PinsListMessage['pins'] = [];
2811
+ let supported = false;
2812
+ try {
2813
+ const cm = this.pinnableCm(agentName);
2814
+ pins = (cm.listPins!() ?? []).map((p) => ({
2815
+ id: String(p.id),
2816
+ firstMessageId: String(p.firstMessageId),
2817
+ lastMessageId: String(p.lastMessageId),
2818
+ kind: p.kind === 'document' ? 'document' : 'pin',
2819
+ ...(typeof p.name === 'string' ? { name: p.name } : {}),
2820
+ created: typeof p.created === 'number' ? p.created : 0,
2821
+ ...(typeof p.level === 'number' ? { level: p.level } : {}),
2822
+ ...(typeof p.maxLevel === 'number' ? { maxLevel: p.maxLevel } : {}),
2823
+ }));
2824
+ supported = true;
2825
+ } catch {
2826
+ supported = false;
2827
+ }
2828
+
2829
+ let levelHonored = false;
2830
+ let deepestLevel: number | undefined;
2831
+ try {
2832
+ const strategyCfg = (app.recipe.agent as unknown as {
2833
+ strategy?: { foldingStrategy?: string };
2834
+ }).strategy;
2835
+ levelHonored = strategyCfg?.foldingStrategy === 'kv-stable';
2836
+ const agent = app.framework.getAgent(agentName);
2837
+ const cm = agent?.getContextManager() as unknown as {
2838
+ getSummaries?: () => Array<{ level?: number }>;
2839
+ } | undefined;
2840
+ const sums = cm?.getSummaries?.() ?? [];
2841
+ for (const s of sums) {
2842
+ if (typeof s.level === 'number' && (deepestLevel === undefined || s.level > deepestLevel)) {
2843
+ deepestLevel = s.level;
2844
+ }
2845
+ }
2846
+ } catch { /* informational only */ }
2847
+
2848
+ return {
2849
+ type: 'pins-list',
2850
+ agent: agentName,
2851
+ pins,
2852
+ pinsSupported: supported,
2853
+ levelHonored,
2854
+ ...(deepestLevel !== undefined ? { deepestLevel } : {}),
2855
+ };
2856
+ }
2857
+
2858
+ private broadcastPinsList(agentName: string): void {
2859
+ if (!sharedServer?.app) return;
2860
+ const msg = this.buildPinsList(agentName);
2861
+ if (!msg) return;
2862
+ for (const c of sharedServer.clients.values()) {
2863
+ if (c.welcomed) this.send(c, msg);
2864
+ }
2865
+ }
2866
+
2715
2867
  /** Default to the recipe's primary agent when the client omits a name. */
2716
2868
  private resolveSettingsAgent(name?: string): string {
2717
2869
  if (name) return name;
package/src/recipe.ts CHANGED
@@ -602,6 +602,27 @@ export interface RecipeExtension {
602
602
  sourceMeta?: Record<string, unknown>;
603
603
  }
604
604
 
605
+ /**
606
+ * Client-side programmatic tool calling (af `code_execution` tool, af ≥0.7.2).
607
+ *
608
+ * SECURITY: like mcpl_deploy, enabling this means trusting the agent with
609
+ * code execution — model-authored python runs as the host user with the
610
+ * agent's full tool surface injected. It is a robustness boundary (deadline,
611
+ * kill, respawn), not a sandbox. The agents we host already have shells;
612
+ * enable deliberately all the same.
613
+ */
614
+ export interface RecipeCodeExecution {
615
+ enabled: boolean;
616
+ /** Python interpreter (default: python3 from PATH; needs ≥3.8). */
617
+ pythonPath?: string;
618
+ /** Per-inner-tool-call timeout, ms (default 270_000). */
619
+ toolCallTimeoutMs?: number;
620
+ /** Whole-script deadline, ms (default 600_000). */
621
+ scriptTimeoutMs?: number;
622
+ /** Idle interpreter reclaim, ms (default 300_000; 0 disables). */
623
+ idleReclaimMs?: number;
624
+ }
625
+
605
626
  export interface Recipe {
606
627
  name: string;
607
628
  description?: string;
@@ -612,6 +633,8 @@ export interface Recipe {
612
633
  /** Deployment-specific code extensions, keyed by a human-readable name. */
613
634
  extensions?: Record<string, RecipeExtension>;
614
635
  sessionNaming?: { examples?: string[] };
636
+ /** Client-side programmatic tool calling (code_execution tool). */
637
+ codeExecution?: RecipeCodeExecution;
615
638
  }
616
639
 
617
640
  // ---------------------------------------------------------------------------
@@ -1234,6 +1257,24 @@ export function validateRecipe(raw: unknown): Recipe {
1234
1257
  }
1235
1258
  }
1236
1259
 
1260
+ if (obj.codeExecution !== undefined) {
1261
+ if (!obj.codeExecution || typeof obj.codeExecution !== 'object' || Array.isArray(obj.codeExecution)) {
1262
+ throw new Error('Recipe codeExecution must be an object.');
1263
+ }
1264
+ const ce = obj.codeExecution as Record<string, unknown>;
1265
+ if (typeof ce.enabled !== 'boolean') {
1266
+ throw new Error('Recipe codeExecution.enabled must be a boolean.');
1267
+ }
1268
+ if (ce.pythonPath !== undefined && (typeof ce.pythonPath !== 'string' || !ce.pythonPath.trim())) {
1269
+ throw new Error('Recipe codeExecution.pythonPath must be a non-empty string.');
1270
+ }
1271
+ for (const k of ['toolCallTimeoutMs', 'scriptTimeoutMs', 'idleReclaimMs'] as const) {
1272
+ if (ce[k] !== undefined && (typeof ce[k] !== 'number' || (ce[k] as number) < 0)) {
1273
+ throw new Error(`Recipe codeExecution.${k} must be a non-negative number.`);
1274
+ }
1275
+ }
1276
+ }
1277
+
1237
1278
  return obj as unknown as Recipe;
1238
1279
  }
1239
1280
 
@@ -391,6 +391,35 @@ export interface McplListMessage {
391
391
  }>;
392
392
  }
393
393
 
394
+ /**
395
+ * Protected ranges (pins / documents) for one agent.
396
+ *
397
+ * BROADCAST on change: pins alter what the next compile folds, so two operators
398
+ * must not hold divergent views of them.
399
+ */
400
+ export interface PinsListMessage {
401
+ type: 'pins-list';
402
+ agent: string;
403
+ pins: Array<{
404
+ id: string;
405
+ firstMessageId: string;
406
+ lastMessageId: string;
407
+ kind: 'pin' | 'document';
408
+ name?: string;
409
+ created: number;
410
+ /** Pinned AT exactly this fold level (0 = raw). */
411
+ level?: number;
412
+ /** Hard cap on fold depth. */
413
+ maxLevel?: number;
414
+ }>;
415
+ /** False when the active strategy has no pin support — panel goes read-only. */
416
+ pinsSupported: boolean;
417
+ /** False when `level` would silently degrade to raw (non-kv-stable folding). */
418
+ levelHonored: boolean;
419
+ /** Deepest fold level currently present, so the UI can bound level inputs. */
420
+ deepestLevel?: number;
421
+ }
422
+
394
423
  /**
395
424
  * Runtime context-settings snapshot.
396
425
  *
@@ -510,6 +539,7 @@ export type WebUiServerMessage =
510
539
  | LessonsListMessage
511
540
  | McplListMessage
512
541
  | SettingsStateMessage
542
+ | PinsListMessage
513
543
  | WorkspaceMountsMessage
514
544
  | WorkspaceTreeMessage
515
545
  | WorkspaceFileMessage
@@ -669,6 +699,46 @@ export interface McplSetEnvMessage {
669
699
  env: Record<string, string>;
670
700
  }
671
701
 
702
+ /** Pull the agent's protected ranges. Response is a `pins-list` envelope. */
703
+ export interface RequestPinsMessage {
704
+ type: 'request-pins';
705
+ agent?: string;
706
+ }
707
+
708
+ /**
709
+ * Create a protected range.
710
+ *
711
+ * Three distinct semantics, deliberately not collapsed into one "pin":
712
+ * - neither `level` nor `maxLevel` → classic pin: forced RAW, never folded
713
+ * - `maxLevel: k` → fold no deeper than L_k (`0` is equivalent to a classic pin)
714
+ * - `level: k` → pin AT exactly L_k; the frontier cut passes through that
715
+ * node, neither deeper nor shallower
716
+ *
717
+ * `kind: 'document'` marks a single message as a document instead of pinning a
718
+ * range, and ignores `lastMessageId`.
719
+ *
720
+ * NOTE: `level` is honored only by `foldingStrategy: 'kv-stable'`. Other
721
+ * strategies degrade it to raw — a safe superset, but not what was asked for.
722
+ * `pins-list.levelHonored` reports which case the live agent is in.
723
+ */
724
+ export interface PinAddMessage {
725
+ type: 'pin-add';
726
+ agent?: string;
727
+ kind?: 'pin' | 'document';
728
+ firstMessageId: string;
729
+ /** Ignored for `kind: 'document'`; defaults to `firstMessageId` for a pin. */
730
+ lastMessageId?: string;
731
+ level?: number;
732
+ maxLevel?: number;
733
+ name?: string;
734
+ }
735
+
736
+ export interface PinRemoveMessage {
737
+ type: 'pin-remove';
738
+ agent?: string;
739
+ pinId: string;
740
+ }
741
+
672
742
  /** Pull the current runtime context settings for an agent, plus which knobs are
673
743
  * live-appliable vs restart-only. Response is a `settings-state` envelope. */
674
744
  export interface RequestSettingsMessage {
@@ -701,6 +771,10 @@ export interface SettingsUpdateMessage {
701
771
  contextBudgetTokens?: number;
702
772
  tailTokens?: number;
703
773
  transitionPaceTokens?: number;
774
+ /** Apply a budget DECREASE immediately (one-shot fold-down, KV
775
+ * invalidation paid this turn) instead of starting a paced descent.
776
+ * The emergency lever for refusal streaks / over-wall wedges. */
777
+ immediate?: boolean;
704
778
  persist?: boolean;
705
779
  notify?: boolean;
706
780
  }
@@ -794,6 +868,9 @@ export type WebUiClientMessage =
794
868
  | McplAddMessage
795
869
  | McplRemoveMessage
796
870
  | McplSetEnvMessage
871
+ | RequestPinsMessage
872
+ | PinAddMessage
873
+ | PinRemoveMessage
797
874
  | RequestSettingsMessage
798
875
  | SettingsUpdateMessage
799
876
  | SettingsResetMessage
@@ -860,11 +937,32 @@ export function isClientMessage(value: unknown): value is WebUiClientMessage {
860
937
  return isValidMcplId(v.id);
861
938
  case 'mcpl-set-env':
862
939
  return isValidMcplId(v.id) && isStringMap(v.env);
940
+ case 'request-pins':
941
+ return isOptionalNonEmptyString(v.agent);
942
+ case 'pin-add': {
943
+ if (!isOptionalNonEmptyString(v.agent)) return false;
944
+ if (!isNonEmptyString(v.firstMessageId)) return false;
945
+ if (v.lastMessageId !== undefined && !isNonEmptyString(v.lastMessageId)) return false;
946
+ if (v.kind !== undefined && v.kind !== 'pin' && v.kind !== 'document') return false;
947
+ if (v.name !== undefined && !isNonEmptyString(v.name)) return false;
948
+ // Fold levels are small non-negative integers (0 = raw). Reject strings
949
+ // and fractions before they reach the strategy's pin normalizer.
950
+ for (const k of ['level', 'maxLevel']) {
951
+ const x = v[k];
952
+ if (x === undefined) continue;
953
+ if (typeof x !== 'number' || !Number.isSafeInteger(x) || x < 0 || x > 32) return false;
954
+ }
955
+ // level and maxLevel are different semantics; asking for both is ambiguous.
956
+ if (v.level !== undefined && v.maxLevel !== undefined) return false;
957
+ return true;
958
+ }
959
+ case 'pin-remove':
960
+ return isOptionalNonEmptyString(v.agent) && isNonEmptyString(v.pinId);
863
961
  case 'request-settings':
864
962
  return isOptionalNonEmptyString(v.agent);
865
963
  case 'settings-update': {
866
964
  if (!isOptionalNonEmptyString(v.agent)) return false;
867
- if (!isOptionalBool(v.persist) || !isOptionalBool(v.notify)) return false;
965
+ if (!isOptionalBool(v.persist) || !isOptionalBool(v.notify) || !isOptionalBool(v.immediate)) return false;
868
966
  // Positive-integer-or-absent for each knob. The semantic gate (budget must
869
967
  // exceed max response tokens; tail/pace need a hot-configurable strategy)
870
968
  // lives in Agent.validateRuntimeSettingsPatch — this only guarantees the
@@ -62,3 +62,40 @@ describe('settings messages — wire validation', () => {
62
62
  expect(isClientMessage({ type: 'request-settings', agent: 7 })).toBe(false);
63
63
  });
64
64
  });
65
+
66
+ describe('pin messages — wire validation', () => {
67
+ it('accepts a single-message raw pin', () => {
68
+ expect(isClientMessage({ type: 'pin-add', firstMessageId: 'm-1' })).toBe(true);
69
+ });
70
+
71
+ it('accepts a range, a document, a label, and level modes', () => {
72
+ expect(isClientMessage({ type: 'pin-add', firstMessageId: 'm-1', lastMessageId: 'm-9' })).toBe(true);
73
+ expect(isClientMessage({ type: 'pin-add', kind: 'document', firstMessageId: 'm-1' })).toBe(true);
74
+ expect(isClientMessage({ type: 'pin-add', firstMessageId: 'm-1', name: 'seed' })).toBe(true);
75
+ expect(isClientMessage({ type: 'pin-add', firstMessageId: 'm-1', level: 0 })).toBe(true);
76
+ expect(isClientMessage({ type: 'pin-add', firstMessageId: 'm-1', maxLevel: 2 })).toBe(true);
77
+ });
78
+
79
+ it('rejects level AND maxLevel together — different semantics, ambiguous intent', () => {
80
+ expect(isClientMessage({ type: 'pin-add', firstMessageId: 'm-1', level: 1, maxLevel: 2 })).toBe(false);
81
+ });
82
+
83
+ it('rejects bad levels and bad ids', () => {
84
+ for (const bad of ['1', 1.5, -1, 33, NaN, null, {}]) {
85
+ expect(isClientMessage({ type: 'pin-add', firstMessageId: 'm-1', level: bad })).toBe(false);
86
+ }
87
+ expect(isClientMessage({ type: 'pin-add', firstMessageId: '' })).toBe(false);
88
+ expect(isClientMessage({ type: 'pin-add' })).toBe(false);
89
+ expect(isClientMessage({ type: 'pin-add', firstMessageId: 'm-1', kind: 'bogus' })).toBe(false);
90
+ expect(isClientMessage({ type: 'pin-add', firstMessageId: 'm-1', lastMessageId: '' })).toBe(false);
91
+ });
92
+
93
+ it('validates pin-remove and request-pins', () => {
94
+ expect(isClientMessage({ type: 'pin-remove', pinId: 'pin-3' })).toBe(true);
95
+ expect(isClientMessage({ type: 'pin-remove' })).toBe(false);
96
+ expect(isClientMessage({ type: 'pin-remove', pinId: '' })).toBe(false);
97
+ expect(isClientMessage({ type: 'request-pins' })).toBe(true);
98
+ expect(isClientMessage({ type: 'request-pins', agent: 'mythos' })).toBe(true);
99
+ expect(isClientMessage({ type: 'request-pins', agent: 5 })).toBe(false);
100
+ });
101
+ });
package/web/src/App.tsx CHANGED
@@ -11,6 +11,7 @@ import { LessonsPanel, type LessonRow } from './Lessons';
11
11
  import { McplPanel, type McplServerRow } from './Mcpl';
12
12
  import { SettingsPanel, type SettingsState } from './Settings';
13
13
  import { DryContext, type DryContextData } from './DryContext';
14
+ import { PinsPanel, type PinsState } from './Pins';
14
15
  import { FilesPanel, FileViewerModal, type Mount, type FlatEntry, type FileViewer } from './Files';
15
16
  import { ContextPanel } from './Context';
16
17
  import { ContextDocument } from './ContextDocument';
@@ -274,7 +275,7 @@ export function App() {
274
275
 
275
276
  /** Right-sidebar tab selection. The Tree is the most-used surface so it's
276
277
  * the default; lessons / mcp / files are operator-driven panels. */
277
- type SidebarTab = 'tree' | 'lessons' | 'mcp' | 'files' | 'context' | 'settings' | 'health';
278
+ type SidebarTab = 'tree' | 'lessons' | 'mcp' | 'files' | 'context' | 'settings' | 'pins' | 'health';
278
279
  const [sidebarTab, setSidebarTab] = createSignal<SidebarTab>('tree');
279
280
  const [mainView, setMainView] = createSignal<'chat' | 'context' | 'dry'>('chat');
280
281
  /** Rendered dry-run context awaiting display. Never applied — see DryContext. */
@@ -325,6 +326,15 @@ export function App() {
325
326
  wire.send({ type: 'request-settings' });
326
327
  };
327
328
 
329
+ /** Pins panel state — broadcast on change, like settings: pins alter the next
330
+ * compile's fold plan, so operators must not hold divergent views. */
331
+ const [pinsState, setPinsState] = createSignal<PinsState | null>(null);
332
+ const [pinsLoaded, setPinsLoaded] = createSignal(false);
333
+ const refreshPins = (): void => {
334
+ setPinsLoaded(false);
335
+ wire.send({ type: 'request-pins' });
336
+ };
337
+
328
338
  /** Workspace files panel state — mounts list + per-mount tree cache. */
329
339
  const [mounts, setMounts] = createSignal<Mount[]>([]);
330
340
  const [mountsLoaded, setMountsLoaded] = createSignal(false);
@@ -894,6 +904,10 @@ export function App() {
894
904
  setSettingsLoaded(true);
895
905
  setSettingsState(state);
896
906
  },
907
+ setPins: (state) => {
908
+ setPinsLoaded(true);
909
+ setPinsState(state);
910
+ },
897
911
  setMounts: (loaded, moduleLoaded, list) => {
898
912
  setMountsLoaded(loaded);
899
913
  setWorkspaceModuleLoaded(moduleLoaded);
@@ -1201,6 +1215,7 @@ export function App() {
1201
1215
  if (tab === 'mcp' && !mcplLoaded()) refreshMcpl();
1202
1216
  if (tab === 'files' && !mountsLoaded()) refreshMounts();
1203
1217
  if (tab === 'settings' && !settingsLoaded()) refreshSettings();
1218
+ if (tab === 'pins' && !pinsLoaded()) refreshPins();
1204
1219
  }}
1205
1220
  />
1206
1221
  <div class="flex-1 min-h-0">
@@ -1251,6 +1266,16 @@ export function App() {
1251
1266
  onDryContext={(ctx) => { setDryContext(ctx as DryContextData); setMainView('dry'); }}
1252
1267
  />
1253
1268
  </Show>
1269
+ <Show when={sidebarTab() === 'pins'}>
1270
+ <PinsPanel
1271
+ loaded={pinsLoaded()}
1272
+ state={pinsState()}
1273
+ agent={pinsState()?.agent}
1274
+ onRefresh={refreshPins}
1275
+ onAdd={(input) => wire.send({ type: 'pin-add', ...input })}
1276
+ onRemove={(pinId) => wire.send({ type: 'pin-remove', pinId })}
1277
+ />
1278
+ </Show>
1254
1279
  <Show when={sidebarTab() === 'files'}>
1255
1280
  <FilesPanel
1256
1281
  loaded={mountsLoaded()}
@@ -1323,6 +1348,8 @@ interface HandlerHooks {
1323
1348
  setMcpl: (configPath: string, servers: McplServerRow[]) => void;
1324
1349
  /** Apply a settings-state broadcast. */
1325
1350
  setSettings: (state: SettingsState) => void;
1351
+ /** Apply a pins-list broadcast. */
1352
+ setPins: (state: PinsState) => void;
1326
1353
  /** Apply a workspace-mounts response. */
1327
1354
  setMounts: (loaded: boolean, moduleLoaded: boolean, mounts: Mount[]) => void;
1328
1355
  /** Apply a workspace-tree response for one mount. */
@@ -1456,6 +1483,9 @@ function handleServerMessage(
1456
1483
  case 'settings-state':
1457
1484
  hooks.setSettings(msg as unknown as SettingsState);
1458
1485
  return;
1486
+ case 'pins-list':
1487
+ hooks.setPins(msg as unknown as PinsState);
1488
+ return;
1459
1489
  case 'workspace-mounts':
1460
1490
  hooks.setMounts(true, msg.loaded, msg.mounts);
1461
1491
  return;
@@ -1986,7 +2016,7 @@ function CommandSuggestions(props: { draft: string; onPick: (cmd: string) => voi
1986
2016
  );
1987
2017
  }
1988
2018
 
1989
- type SidebarTabId = 'tree' | 'lessons' | 'mcp' | 'files' | 'context' | 'settings' | 'health';
2019
+ type SidebarTabId = 'tree' | 'lessons' | 'mcp' | 'files' | 'context' | 'settings' | 'pins' | 'health';
1990
2020
 
1991
2021
  function SidebarTabs(props: {
1992
2022
  current: SidebarTabId;
@@ -1999,6 +2029,7 @@ function SidebarTabs(props: {
1999
2029
  { id: 'files', label: 'Files', title: 'Workspace mounts + files' },
2000
2030
  { id: 'context', label: 'Context', title: 'Compiled context makeup' },
2001
2031
  { id: 'settings', label: 'Settings', title: 'Live context budget / tail, with preview' },
2032
+ { id: 'pins', label: 'Pins', title: 'Protected ranges — constrain what the fold plan may fold' },
2002
2033
  { id: 'health', label: 'Health', title: 'Runtime settings, failures, quarantine' },
2003
2034
  ];
2004
2035
  return (
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Pins panel — operator control over protected ranges.
3
+ *
4
+ * Three semantics, kept visibly distinct because collapsing them into one
5
+ * "pin" button would hide what actually happens to the fold plan:
6
+ *
7
+ * raw — never folded (classic pin)
8
+ * max L_k — fold no deeper than k (k=0 is equivalent to raw)
9
+ * at L_k — pinned AT exactly k; the frontier cut passes through that node
10
+ *
11
+ * `at L_k` is honored only by the kv-stable folding strategy. Elsewhere it
12
+ * degrades to raw — a safe superset, but not what was asked for — so the panel
13
+ * warns rather than letting it pass silently.
14
+ *
15
+ * Message ids come from /debug/context/curve (cheap: ~14ms) so the operator
16
+ * picks from the live context instead of pasting ids.
17
+ */
18
+
19
+ import { createSignal, For, Show } from 'solid-js';
20
+
21
+ export interface PinRow {
22
+ id: string;
23
+ firstMessageId: string;
24
+ lastMessageId: string;
25
+ kind: 'pin' | 'document';
26
+ name?: string;
27
+ created: number;
28
+ level?: number;
29
+ maxLevel?: number;
30
+ }
31
+
32
+ export interface PinsState {
33
+ agent: string;
34
+ pins: PinRow[];
35
+ pinsSupported: boolean;
36
+ levelHonored: boolean;
37
+ deepestLevel?: number;
38
+ }
39
+
40
+ interface CurveEntry {
41
+ i: number;
42
+ kind: string;
43
+ id: string | null;
44
+ participant?: string;
45
+ rendered?: number;
46
+ msgCount?: number;
47
+ text?: string;
48
+ }
49
+
50
+ const ago = (ms: number) => {
51
+ if (!ms) return '—';
52
+ const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
53
+ if (s < 60) return `${s}s`;
54
+ if (s < 3600) return `${Math.round(s / 60)}m`;
55
+ if (s < 86400) return `${Math.round(s / 3600)}h`;
56
+ return `${Math.round(s / 86400)}d`;
57
+ };
58
+
59
+ /** What the fold plan will actually do with this range. */
60
+ function describe(p: PinRow): string {
61
+ if (p.level !== undefined) return p.level === 0 ? 'at raw' : `at L${p.level}`;
62
+ if (p.maxLevel !== undefined) return p.maxLevel === 0 ? 'raw (max L0)' : `max L${p.maxLevel}`;
63
+ return 'raw';
64
+ }
65
+
66
+ export function PinsPanel(props: {
67
+ loaded: boolean;
68
+ state: PinsState | null;
69
+ agent?: string;
70
+ onRefresh(): void;
71
+ onAdd(input: {
72
+ kind: 'pin' | 'document';
73
+ firstMessageId: string;
74
+ lastMessageId?: string;
75
+ level?: number;
76
+ maxLevel?: number;
77
+ name?: string;
78
+ }): void;
79
+ onRemove(pinId: string): void;
80
+ }) {
81
+ const [first, setFirst] = createSignal('');
82
+ const [last, setLast] = createSignal('');
83
+ const [name, setName] = createSignal('');
84
+ const [mode, setMode] = createSignal<'raw' | 'max' | 'at'>('raw');
85
+ const [lvl, setLvl] = createSignal('1');
86
+ const [asDoc, setAsDoc] = createSignal(false);
87
+
88
+ const [curve, setCurve] = createSignal<CurveEntry[] | null>(null);
89
+ const [curveErr, setCurveErr] = createSignal<string | null>(null);
90
+ const [picking, setPicking] = createSignal(false);
91
+
92
+ /** /debug/context/curve is the cheap endpoint (~14ms) and the only one that
93
+ * exposes per-entry store ids alongside a text preview. */
94
+ const loadCurve = async () => {
95
+ setPicking(true);
96
+ setCurveErr(null);
97
+ try {
98
+ const qs = props.agent ? `?agent=${encodeURIComponent(props.agent)}` : '';
99
+ const res = await fetch(`/debug/context/curve${qs}`, { credentials: 'same-origin' });
100
+ const body = await res.json();
101
+ if (!res.ok) { setCurveErr(body?.error ?? `HTTP ${res.status}`); return; }
102
+ setCurve((body.entries ?? []) as CurveEntry[]);
103
+ } catch (e) {
104
+ setCurveErr(e instanceof Error ? e.message : String(e));
105
+ } finally {
106
+ setPicking(false);
107
+ }
108
+ };
109
+
110
+ const submit = () => {
111
+ const f = first().trim();
112
+ if (!f) return;
113
+ const n = Number(lvl());
114
+ const levelOk = Number.isSafeInteger(n) && n >= 0 && n <= 32;
115
+ props.onAdd({
116
+ kind: asDoc() ? 'document' : 'pin',
117
+ firstMessageId: f,
118
+ ...(asDoc() || !last().trim() ? {} : { lastMessageId: last().trim() }),
119
+ ...(mode() === 'at' && levelOk ? { level: n } : {}),
120
+ ...(mode() === 'max' && levelOk ? { maxLevel: n } : {}),
121
+ ...(name().trim() ? { name: name().trim() } : {}),
122
+ });
123
+ setFirst(''); setLast(''); setName('');
124
+ };
125
+
126
+ return (
127
+ <div class="h-full overflow-y-auto px-3 py-2 text-xs">
128
+ <div class="flex items-center gap-2 mb-2">
129
+ <span class="text-neutral-500 uppercase tracking-wider text-[10px] font-semibold">pins</span>
130
+ <Show when={props.state}>
131
+ <span class="text-neutral-600 text-[10px]">{props.state!.pins.length}</span>
132
+ </Show>
133
+ <button
134
+ type="button"
135
+ class="ml-auto px-2 py-0.5 text-[10px] bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded font-mono"
136
+ onClick={() => props.onRefresh()}
137
+ >
138
+ refresh
139
+ </button>
140
+ </div>
141
+
142
+ <Show when={!props.loaded}><div class="text-neutral-600 italic">Loading…</div></Show>
143
+
144
+ <Show when={props.state && !props.state.pinsSupported}>
145
+ <div class="text-amber-400/80 text-[11px]">
146
+ The active context strategy does not support pins.
147
+ </div>
148
+ </Show>
149
+
150
+ <Show when={props.state?.pinsSupported}>
151
+ <div class="text-[10px] text-neutral-600 mb-2 leading-relaxed">
152
+ Pins constrain the fold plan and take effect on the <b>next compile</b> — no restart.
153
+ They persist and are branch-scoped. Pair with a dry run to see the effect before it
154
+ reaches a real turn.
155
+ </div>
156
+
157
+ <Show when={!props.state!.levelHonored}>
158
+ <div class="mb-2 px-2 py-1 rounded bg-amber-950/40 border border-amber-800
159
+ text-[10px] text-amber-200 leading-relaxed">
160
+ This agent is not on <span class="font-mono">kv-stable</span> folding, so
161
+ <b> "at L<sub>k</sub>" degrades to raw</b> — a safe superset, but not what you asked
162
+ for. Prefer <span class="font-mono">raw</span> or <span class="font-mono">max L</span>
163
+ here.
164
+ </div>
165
+ </Show>
166
+
167
+ {/* ---- existing pins ---- */}
168
+ <Show when={props.state!.pins.length === 0}>
169
+ <div class="text-neutral-600 italic mb-3">No pins.</div>
170
+ </Show>
171
+ <Show when={props.state!.pins.length > 0}>
172
+ <table class="w-full mb-3 font-mono text-[10px]">
173
+ <tbody>
174
+ <For each={props.state!.pins}>
175
+ {(p) => (
176
+ <tr class="border-b border-neutral-900">
177
+ <td class="pr-1 align-top">
178
+ <span class={p.kind === 'document' ? 'text-indigo-300' : 'text-cyan-300'}>
179
+ {p.kind === 'document' ? 'doc' : 'pin'}
180
+ </span>
181
+ </td>
182
+ <td class="pr-1 align-top text-neutral-200">{describe(p)}</td>
183
+ <td class="pr-1 align-top text-neutral-400 break-all">
184
+ {p.firstMessageId}
185
+ <Show when={p.lastMessageId !== p.firstMessageId}>
186
+ <span class="text-neutral-600"> → {p.lastMessageId}</span>
187
+ </Show>
188
+ <Show when={p.name}>
189
+ <div class="text-neutral-500">“{p.name}”</div>
190
+ </Show>
191
+ </td>
192
+ <td class="pr-1 align-top text-neutral-600">{ago(p.created)}</td>
193
+ <td class="align-top text-right">
194
+ <button
195
+ type="button"
196
+ class="px-1.5 py-0.5 rounded bg-neutral-800 hover:bg-red-900/60
197
+ text-neutral-400 hover:text-red-200"
198
+ title={`unpin ${p.id}`}
199
+ onClick={() => props.onRemove(p.id)}
200
+ >
201
+ unpin
202
+ </button>
203
+ </td>
204
+ </tr>
205
+ )}
206
+ </For>
207
+ </tbody>
208
+ </table>
209
+ </Show>
210
+
211
+ {/* ---- add ---- */}
212
+ <div class="border-t border-neutral-800 pt-2">
213
+ <div class="text-neutral-500 uppercase tracking-wider text-[10px] font-semibold mb-1.5">
214
+ add
215
+ </div>
216
+
217
+ <div class="space-y-1.5 mb-2">
218
+ <label class="flex items-center gap-2">
219
+ <span class="text-neutral-500 w-10">from</span>
220
+ <input value={first()} onInput={(e) => setFirst(e.currentTarget.value)}
221
+ placeholder="message id"
222
+ class="flex-1 bg-neutral-900 border border-neutral-700 rounded px-1.5 py-0.5
223
+ font-mono text-[11px] text-neutral-100" />
224
+ </label>
225
+ <label class="flex items-center gap-2" title={asDoc() ? 'documents cover a single message' : ''}>
226
+ <span class="text-neutral-500 w-10">to</span>
227
+ <input value={last()} disabled={asDoc()}
228
+ onInput={(e) => setLast(e.currentTarget.value)}
229
+ placeholder={asDoc() ? 'n/a for documents' : 'optional — defaults to “from”'}
230
+ class="flex-1 bg-neutral-900 border border-neutral-700 rounded px-1.5 py-0.5
231
+ font-mono text-[11px] text-neutral-100 disabled:opacity-40" />
232
+ </label>
233
+ <label class="flex items-center gap-2">
234
+ <span class="text-neutral-500 w-10">label</span>
235
+ <input value={name()} onInput={(e) => setName(e.currentTarget.value)}
236
+ placeholder="optional"
237
+ class="flex-1 bg-neutral-900 border border-neutral-700 rounded px-1.5 py-0.5
238
+ font-mono text-[11px] text-neutral-100" />
239
+ </label>
240
+ </div>
241
+
242
+ <div class="flex items-center gap-3 mb-2 text-[10px] text-neutral-400 flex-wrap">
243
+ <For each={[['raw', 'raw'], ['max', 'max L'], ['at', 'at L']] as Array<[string, string]>}>
244
+ {([v, label]) => (
245
+ <label class="flex items-center gap-1">
246
+ <input type="radio" name="pinmode" checked={mode() === v}
247
+ onChange={() => setMode(v as 'raw' | 'max' | 'at')} />
248
+ {label}
249
+ </label>
250
+ )}
251
+ </For>
252
+ <Show when={mode() !== 'raw'}>
253
+ <input type="number" min="0" max={props.state!.deepestLevel ?? 8}
254
+ value={lvl()} onInput={(e) => setLvl(e.currentTarget.value)}
255
+ class="w-14 bg-neutral-900 border border-neutral-700 rounded px-1 py-0.5
256
+ font-mono text-[11px] text-neutral-100" />
257
+ <Show when={props.state!.deepestLevel !== undefined}>
258
+ <span class="text-neutral-600">deepest present: L{props.state!.deepestLevel}</span>
259
+ </Show>
260
+ </Show>
261
+ <label class="flex items-center gap-1">
262
+ <input type="checkbox" checked={asDoc()} onChange={(e) => setAsDoc(e.currentTarget.checked)} />
263
+ as document
264
+ </label>
265
+ </div>
266
+
267
+ <div class="flex items-center gap-2 mb-3">
268
+ <button type="button" disabled={!first().trim()}
269
+ class="px-2 py-0.5 text-[10px] rounded font-mono bg-cyan-900/50 hover:bg-cyan-900/70
270
+ text-cyan-200 disabled:opacity-30"
271
+ onClick={submit}>
272
+ add pin
273
+ </button>
274
+ <button type="button"
275
+ class="px-2 py-0.5 text-[10px] rounded font-mono bg-neutral-800 hover:bg-neutral-700 text-neutral-300"
276
+ onClick={() => void loadCurve()}>
277
+ {picking() ? 'loading…' : curve() ? 'reload ids' : 'pick from context'}
278
+ </button>
279
+ </div>
280
+
281
+ <Show when={curveErr()}>
282
+ <div class="text-[10px] text-amber-400/90 mb-2">{curveErr()}</div>
283
+ </Show>
284
+
285
+ {/* ---- id picker ---- */}
286
+ <Show when={curve()}>
287
+ <div class="max-h-64 overflow-y-auto border border-neutral-800 rounded">
288
+ <For each={curve()!.filter((e) => e.id)}>
289
+ {(e) => (
290
+ <div class="flex items-start gap-1.5 px-1.5 py-1 border-b border-neutral-900
291
+ hover:bg-neutral-900/60">
292
+ <span class="text-[9px] font-mono text-neutral-600 w-8 shrink-0">#{e.i}</span>
293
+ <span class={`text-[9px] font-mono w-8 shrink-0 ${
294
+ e.kind === 'raw' ? 'text-emerald-400/80' : 'text-orange-400/80'
295
+ }`}>{e.kind}</span>
296
+ <span class="text-[10px] text-neutral-400 flex-1 truncate"
297
+ title={e.text ?? ''}>{(e.text ?? '').slice(0, 90)}</span>
298
+ <button type="button"
299
+ class="text-[9px] font-mono px-1 rounded bg-neutral-800 hover:bg-neutral-700
300
+ text-neutral-400 shrink-0"
301
+ onClick={() => setFirst(e.id!)}>from</button>
302
+ <button type="button" disabled={asDoc()}
303
+ class="text-[9px] font-mono px-1 rounded bg-neutral-800 hover:bg-neutral-700
304
+ text-neutral-400 shrink-0 disabled:opacity-30"
305
+ onClick={() => setLast(e.id!)}>to</button>
306
+ </div>
307
+ )}
308
+ </For>
309
+ </div>
310
+ <div class="text-[10px] text-neutral-600 mt-1">
311
+ Entries without a store id (merged summaries) are omitted — a pin needs a message id.
312
+ </div>
313
+ </Show>
314
+ </div>
315
+ </Show>
316
+ </div>
317
+ );
318
+ }
@@ -96,6 +96,7 @@ export function SettingsPanel(props: {
96
96
  contextBudgetTokens?: number;
97
97
  tailTokens?: number;
98
98
  transitionPaceTokens?: number;
99
+ immediate?: boolean;
99
100
  persist: boolean;
100
101
  notify: boolean;
101
102
  }): void;
@@ -109,6 +110,7 @@ export function SettingsPanel(props: {
109
110
  const [pace, setPace] = createSignal<string>('');
110
111
  const [persist, setPersist] = createSignal(true);
111
112
  const [notify, setNotify] = createSignal(false);
113
+ const [immediate, setImmediate] = createSignal(false);
112
114
 
113
115
  const [preview, setPreview] = createSignal<PreviewResult | null>(null);
114
116
  const [acct, setAcct] = createSignal<PreviewAccounting | null>(null);
@@ -196,7 +198,12 @@ export function SettingsPanel(props: {
196
198
  if (t !== undefined && t !== cur?.tailTokens) patch.tailTokens = t;
197
199
  if (p !== undefined && p !== cur?.transitionPaceTokens) patch.transitionPaceTokens = p;
198
200
  if (Object.keys(patch).length === 0) return;
199
- props.onApply({ ...patch, persist: persist(), notify: notify() });
201
+ props.onApply({
202
+ ...patch,
203
+ ...(lowering() && immediate() ? { immediate: true } : {}),
204
+ persist: persist(),
205
+ notify: notify(),
206
+ });
200
207
  };
201
208
 
202
209
  const lowering = () => {
@@ -339,10 +346,26 @@ export function SettingsPanel(props: {
339
346
  <div>ephemeral — live now, reverts on restart.</div>
340
347
  </Show>
341
348
  <Show when={lowering()}>
342
- <div class="text-amber-500/90">
343
- lowering the budget converges gradually, not instantly — it will report
344
- <span class="font-mono"> converging</span> until it settles.
345
- </div>
349
+ <label
350
+ class="flex items-center gap-1 text-neutral-400 mb-1"
351
+ title="Skip the paced descent: the next compile plans straight at the new budget. The whole fold-down and its KV-prefix invalidation land on that one turn — the emergency lever for refusal streaks or over-budget wedges. Cancels any in-flight descent."
352
+ >
353
+ <input type="checkbox" checked={immediate()} onChange={(e) => setImmediate(e.currentTarget.checked)} />
354
+ drop immediately
355
+ </label>
356
+ <Show when={!immediate()}>
357
+ <div class="text-amber-500/90">
358
+ lowering the budget converges gradually, not instantly — it will report
359
+ <span class="font-mono"> converging</span> until it settles.
360
+ Check <span class="font-mono">drop immediately</span> to skip the descent.
361
+ </div>
362
+ </Show>
363
+ <Show when={immediate()}>
364
+ <div class="text-red-400/90">
365
+ immediate drop: the full fold-down (and its KV re-read) lands on the next
366
+ turn, and any in-flight descent is cancelled.
367
+ </div>
368
+ </Show>
346
369
  </Show>
347
370
  </div>
348
371
 
@@ -384,7 +407,7 @@ export function SettingsPanel(props: {
384
407
  class="px-2 py-0.5 text-[10px] rounded font-mono bg-neutral-800 hover:bg-neutral-700
385
408
  text-neutral-200 disabled:opacity-30"
386
409
  onClick={() => void runDryRun(false)}
387
- title="Compile at these settings without applying them. Does not commit anything. Measured ~8s on a large store."
410
+ title="Compile at these settings without applying them. Does not commit anything. ~1.6s on a large store after the 2026-07-26 solver fixes."
388
411
  >
389
412
  dry run
390
413
  </button>
@@ -408,7 +431,7 @@ export function SettingsPanel(props: {
408
431
  <div class="text-[10px] text-neutral-600 mb-1.5 leading-relaxed">
409
432
  A dry run is a <b class="text-neutral-500">full compile</b> and it runs on the agent's
410
433
  thread — while it runs the agent does nothing else (no heartbeat, no Discord, no MCPL).
411
- Measured ~8s on a large store; it commits nothing (no fold resolutions, no compression
434
+ Measured ~1.6s on a large store; it commits nothing (no fold resolutions, no compression
412
435
  queued). Runs are serialized with a short cooldown, so a second click is refused rather
413
436
  than queueing another pause.
414
437
  </div>