@animalabs/connectome-host 0.4.0 → 0.5.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,5 +1,55 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.5.0 — 2026-07-26
6
+
7
+ ### Added
8
+
9
+ - **Context settings panel** (webui `Settings` tab) — live control of the
10
+ agent's compile window, replacing the stop → edit the `framework/state`
11
+ Chronicle slot → start dance. Edits `contextBudgetTokens`, `tailTokens` and
12
+ `transitionPaceTokens`; Apply / reset-to-recipe / revert-edits, plus cancel
13
+ for an in-flight descent.
14
+ - New client messages `request-settings`, `settings-update`,
15
+ `settings-reset`, `settings-cancel-transition`; new server frame
16
+ `settings-state`. No protocol version bump (additive).
17
+ - `settings-state` is **broadcast** to every welcomed client, unlike
18
+ `mcpl-list` — these are live process values, so two operators must not see
19
+ divergent budgets.
20
+ - Mutations are full-auth only for free: `observerMaySend` denies by default,
21
+ so new message types are never reachable by scoped observers.
22
+ - `persist: false` applies ephemerally (live now, reverts on restart) for
23
+ operator experiments. `notify: true` optionally pushes a notice to the
24
+ agent; **off by default**, because the notice is new text in the very
25
+ context being tuned — it invalidates the KV prefix and is itself
26
+ classifier-visible. The agent can always pull current settings via its own
27
+ `agent_settings` tool instead.
28
+ - The panel is explicit about three things that would otherwise mislead:
29
+ raising the budget applies at once but **lowering starts a paced
30
+ convergence** (shown as `converging` / `blocked`, with the blocked reason
31
+ spelled out); only a few keys are hot, so `targetChunkTokens`,
32
+ `headWindowTokens`, `mergeThreshold`, `foldingStrategy` and friends are
33
+ listed under "restart only" rather than offered as controls; and preview
34
+ requires a context-manager with dry-run support, so an older build reports
35
+ "preview unavailable" instead of rendering an empty result.
36
+ - **`GET /debug/context/preview?budget=&tail=[&agent=]`** — non-committing
37
+ preview of the fold plan at a hypothetical window. Persists no fold
38
+ resolutions, enqueues no compression, advances no transition bookkeeping
39
+ (the guarantee lives in context-manager's dry-run select). An infeasible
40
+ budget is reported as `fits: false` with per-component diagnostics rather
41
+ than an error — learning a budget cannot work is the reason to preview
42
+ instead of applying and taking the outage. Returns 501 when the resolved
43
+ context-manager predates dry-run support. Requires the `debug` scope.
44
+
45
+ ### Fixed
46
+
47
+ - `/debug/context/curve` compiled against `app.recipe.agent.contextBudgetTokens`
48
+ — the **stale recipe** value. Runtime overrides live in the `framework/state`
49
+ Chronicle slot and win over the recipe, so the curve was plotted at the wrong
50
+ budget for any agent whose budget had ever been changed at runtime. Now reads
51
+ the live `getAgentRuntimeSettings`, falling back to the recipe.
52
+
3
53
  ## 0.4.0
4
54
 
5
55
  ### Changed
package/bun.lock CHANGED
@@ -5,10 +5,10 @@
5
5
  "": {
6
6
  "name": "connectome-host",
7
7
  "dependencies": {
8
- "@animalabs/agent-framework": "^0.6.8",
8
+ "@animalabs/agent-framework": "^0.7.0",
9
9
  "@animalabs/chronicle": "^0.2.0",
10
- "@animalabs/context-manager": "^0.5.13",
11
- "@animalabs/membrane": "^0.5.73",
10
+ "@animalabs/context-manager": "^0.6.0",
11
+ "@animalabs/membrane": "^0.5.75",
12
12
  "@opentui/core": "^0.1.82",
13
13
  },
14
14
  "devDependencies": {
@@ -19,13 +19,13 @@
19
19
  },
20
20
  },
21
21
  "packages": {
22
- "@animalabs/agent-framework": ["@animalabs/agent-framework@0.6.8", "", { "dependencies": { "@animalabs/chronicle": "^0.2.2", "@animalabs/context-manager": "^0.5.13", "@animalabs/membrane": "^0.5.73", "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-hLu+DI0Xj40zSRm+KmjEZO/uzizd2Gbaiih4IGNXJJZf0mcIxYDbOynEMpaLHtXGYMS8eYFmQK02ytdL9thtoA=="],
22
+ "@animalabs/agent-framework": ["@animalabs/agent-framework@0.7.0", "", { "dependencies": { "@animalabs/chronicle": "^0.2.2", "@animalabs/context-manager": "^0.6.0", "@animalabs/membrane": "^0.5.75", "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-9FRQsCPaCZuZe0pyj+xq2NvdbSWQxRvdgO8nojVezpysaJL+w/Ba9tahGaOK7ZP12ZqnX3Q88wlttsXY+A47Gw=="],
23
23
 
24
24
  "@animalabs/chronicle": ["@animalabs/chronicle@0.2.1", "", { "optionalDependencies": { "@animalabs/chronicle-darwin-arm64": "0.2.1", "@animalabs/chronicle-darwin-x64": "0.2.1", "@animalabs/chronicle-linux-arm64-gnu": "0.2.1", "@animalabs/chronicle-linux-x64-gnu": "0.2.1", "@animalabs/chronicle-win32-x64-msvc": "0.2.1" } }, "sha512-rDFc069yfi7BQUusgsXBwBdqljUWLByXSYaTF40AP+tonBh0No3eF9VzKTSy1I8DYg/jAUHIKtVdKDGsVMVubw=="],
25
25
 
26
- "@animalabs/context-manager": ["@animalabs/context-manager@0.5.13", "", { "dependencies": { "@animalabs/chronicle": "^0.2.6", "@animalabs/membrane": "^0.5.69" } }, "sha512-1NOsyCeiJoAa4MrBSGw1zbbs2XfvhRbkMfkFNCNFH9gSFTq/4tMwMu6lmZ082oNeB9FvptGrjCuZdVP8B9/UdA=="],
26
+ "@animalabs/context-manager": ["@animalabs/context-manager@0.6.0", "", { "dependencies": { "@animalabs/chronicle": "^0.2.6", "@animalabs/membrane": "^0.5.69" } }, "sha512-X8PKvHouS0dXt6faHeMyI2FehgyFKPT3vBaqM7QN6o/O/3I5pU8UX+WR1nsixfgw+la83lDC+OHn+lPe1A7DVg=="],
27
27
 
28
- "@animalabs/membrane": ["@animalabs/membrane@0.5.73", "", { "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } }, "sha512-w1rt/JVOD6dWwEO54tfR0yrLzwwD45cpYJ9hWJhHxs8k9FvW1PgIIV1KmNFNbpY8MR9HC5g7Lqgvhzs3pDst+g=="],
28
+ "@animalabs/membrane": ["@animalabs/membrane@0.5.75", "", { "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } }, "sha512-eWtkrbQQga8UWDMH4VzPfZzcwYQLwngUcQ2tMeuERMBJ6WkXQzSJkcQVjM3ZvexvytnLsFTMOATvT58Igl9JuA=="],
29
29
 
30
30
  "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.52.0", "", { "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-d4c+fg+xy9e46c8+YnrrgIQR45CZlAi7PwdzIfDXDM6ACxEZli1/fxhURsq30ZpMZy6LvSkr41jGq5aF5TD7rQ=="],
31
31
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/connectome-host",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "General-purpose agent TUI host with recipe-based configuration",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -58,6 +58,7 @@ import {
58
58
  type TokenUsage,
59
59
  type CallLedgerSnapshot,
60
60
  type McplListMessage,
61
+ type SettingsStateMessage,
61
62
  type BranchesListMessage,
62
63
  type LessonsListMessage,
63
64
  } from '../web/protocol.js';
@@ -1076,6 +1077,9 @@ export class WebUiModule implements Module {
1076
1077
  if (url.pathname === '/debug/context/maintenance') {
1077
1078
  return this.handleContextMaintenance();
1078
1079
  }
1080
+ if (url.pathname === '/debug/context/preview') {
1081
+ return this.handleContextPreview(url);
1082
+ }
1079
1083
  if (url.pathname === '/curve') {
1080
1084
  return new Response(CURVE_PAGE_HTML, {
1081
1085
  headers: { 'content-type': 'text/html; charset=utf-8' },
@@ -1181,6 +1185,79 @@ export class WebUiModule implements Module {
1181
1185
  return Response.json(buildContextCoverageSnapshot(agentName, cm));
1182
1186
  }
1183
1187
 
1188
+ /**
1189
+ * Preview the fold plan at a HYPOTHETICAL budget / tail, without applying it.
1190
+ *
1191
+ * GET /debug/context/preview?budget=<tokens>[&tail=<tokens>][&agent=<name>]
1192
+ *
1193
+ * Commits nothing — no fold resolutions persisted, no compression enqueued,
1194
+ * no transition bookkeeping advanced. That guarantee lives in
1195
+ * context-manager's `previewContext` (dry-run select); this endpoint only
1196
+ * forwards. An infeasible budget is reported as `fits: false` with the
1197
+ * per-component diagnostics, NOT as an error: learning that a budget can't
1198
+ * work is the reason to preview instead of applying and taking the outage.
1199
+ *
1200
+ * Returns 501 when the resolved context-manager predates dry-run support, so
1201
+ * the panel can say so instead of rendering an empty result.
1202
+ */
1203
+ private handleContextPreview(url: URL): Response {
1204
+ const app = sharedServer?.app;
1205
+ if (!app) return Response.json({ error: 'app not bound yet' }, { status: 503 });
1206
+ const agentName = url.searchParams.get('agent') || app.recipe.agent.name || 'agent';
1207
+ const agent = app.framework.getAgent(agentName);
1208
+ if (!agent) {
1209
+ return Response.json({ error: `Agent not found: ${agentName}` }, { status: 404 });
1210
+ }
1211
+
1212
+ const budgetRaw = url.searchParams.get('budget');
1213
+ const budget = budgetRaw === null ? NaN : Number(budgetRaw);
1214
+ if (!Number.isSafeInteger(budget) || budget <= 0) {
1215
+ return Response.json({ error: 'budget must be a positive integer' }, { status: 400 });
1216
+ }
1217
+ const tailRaw = url.searchParams.get('tail');
1218
+ const overrides: Record<string, unknown> = {};
1219
+ if (tailRaw !== null) {
1220
+ const tail = Number(tailRaw);
1221
+ if (!Number.isSafeInteger(tail) || tail < 0) {
1222
+ return Response.json({ error: 'tail must be a non-negative integer' }, { status: 400 });
1223
+ }
1224
+ // The strategy knob behind "tail" is recentWindowTokens.
1225
+ overrides.recentWindowTokens = tail;
1226
+ }
1227
+
1228
+ const fw = app.framework as unknown as {
1229
+ previewContextSettings?: (n: string, b: number, o?: Record<string, unknown>) => unknown;
1230
+ };
1231
+ if (typeof fw.previewContextSettings !== 'function') {
1232
+ return Response.json(
1233
+ { error: 'preview unsupported: this agent-framework build has no previewContextSettings' },
1234
+ { status: 501 },
1235
+ );
1236
+ }
1237
+ try {
1238
+ const result = fw.previewContextSettings(
1239
+ agentName,
1240
+ budget,
1241
+ Object.keys(overrides).length > 0 ? overrides : undefined,
1242
+ );
1243
+ if (result === null || result === undefined) {
1244
+ return Response.json(
1245
+ {
1246
+ error: 'preview unavailable: the resolved context-manager has no dry-run support, '
1247
+ + 'or the active strategy has no fold plan (non-adaptive)',
1248
+ },
1249
+ { status: 501 },
1250
+ );
1251
+ }
1252
+ return Response.json({ agent: agentName, budget, ...(overrides as object), preview: result });
1253
+ } catch (err) {
1254
+ return Response.json(
1255
+ { error: err instanceof Error ? err.message : String(err) },
1256
+ { status: 500 },
1257
+ );
1258
+ }
1259
+ }
1260
+
1184
1261
  /**
1185
1262
  * Debug endpoint: return the membrane-normalized request the framework would
1186
1263
  * hand to the model if the agent were activated right now. Delegates to
@@ -1254,7 +1331,18 @@ export class WebUiModule implements Module {
1254
1331
  }
1255
1332
  try {
1256
1333
  const cm = (agent as unknown as { getContextManager: () => any }).getContextManager();
1257
- const maxTokens = app.recipe.agent.contextBudgetTokens ?? 200_000;
1334
+ // Use the LIVE budget, not the recipe's. Runtime overrides persist in the
1335
+ // `framework/state` Chronicle slot and win over the recipe, so reading
1336
+ // app.recipe here plotted the wrong curve on any agent whose budget had
1337
+ // ever been changed at runtime — which is every agent the settings panel
1338
+ // touches. Fall back to the recipe only if the live read is unavailable.
1339
+ let maxTokens = app.recipe.agent.contextBudgetTokens ?? 200_000;
1340
+ try {
1341
+ const live = (app.framework as unknown as {
1342
+ getAgentRuntimeSettings?: (n: string) => { contextBudgetTokens?: number };
1343
+ }).getAgentRuntimeSettings?.(agentName)?.contextBudgetTokens;
1344
+ if (typeof live === 'number' && live > 0) maxTokens = live;
1345
+ } catch { /* keep the recipe fallback */ }
1258
1346
  const reserveForResponse = app.recipe.agent.maxTokens ?? 16_384;
1259
1347
  const compiled = await cm.compile({ maxTokens, reserveForResponse });
1260
1348
 
@@ -1763,6 +1851,78 @@ export class WebUiModule implements Module {
1763
1851
  return;
1764
1852
  }
1765
1853
 
1854
+ case 'request-settings': {
1855
+ this.sendSettingsState(client, parsed.agent);
1856
+ return;
1857
+ }
1858
+
1859
+ // Settings mutations are live process state, so every case BROADCASTS
1860
+ // rather than replying to the requester only (contrast sendMcplList,
1861
+ // which is file-only). Two operators must not see divergent budgets.
1862
+ case 'settings-update': {
1863
+ const agentName = this.resolveSettingsAgent(parsed.agent);
1864
+ try {
1865
+ const patch: Record<string, number> = {};
1866
+ if (parsed.contextBudgetTokens !== undefined) patch.contextBudgetTokens = parsed.contextBudgetTokens;
1867
+ if (parsed.tailTokens !== undefined) patch.tailTokens = parsed.tailTokens;
1868
+ if (parsed.transitionPaceTokens !== undefined) patch.transitionPaceTokens = parsed.transitionPaceTokens;
1869
+ const fw = sharedServer!.app.framework as unknown as {
1870
+ updateAgentRuntimeSettings: (n: string, p: unknown, o?: { persist?: boolean }) => unknown;
1871
+ };
1872
+ fw.updateAgentRuntimeSettings(agentName, patch, { persist: parsed.persist !== false });
1873
+ } catch (err) {
1874
+ // Expected failures land here and must reach the operator verbatim:
1875
+ // budget ≤ max response tokens, or a strategy that cannot prepare a
1876
+ // smaller window live. Silently swallowing them would look like the
1877
+ // apply worked.
1878
+ this.send(client, {
1879
+ type: 'error',
1880
+ message: `settings-update failed: ${err instanceof Error ? err.message : String(err)}`,
1881
+ });
1882
+ return;
1883
+ }
1884
+ if (parsed.notify === true) this.notifyAgentOfSettingsChange(agentName, 'update');
1885
+ this.broadcastSettingsState(agentName);
1886
+ return;
1887
+ }
1888
+
1889
+ case 'settings-reset': {
1890
+ const agentName = this.resolveSettingsAgent(parsed.agent);
1891
+ try {
1892
+ const fw = sharedServer!.app.framework as unknown as {
1893
+ resetAgentRuntimeSettings: (n: string, k?: string[], o?: { persist?: boolean }) => unknown;
1894
+ };
1895
+ fw.resetAgentRuntimeSettings(agentName, parsed.keys, { persist: parsed.persist !== false });
1896
+ } catch (err) {
1897
+ this.send(client, {
1898
+ type: 'error',
1899
+ message: `settings-reset failed: ${err instanceof Error ? err.message : String(err)}`,
1900
+ });
1901
+ return;
1902
+ }
1903
+ if (parsed.notify === true) this.notifyAgentOfSettingsChange(agentName, 'reset');
1904
+ this.broadcastSettingsState(agentName);
1905
+ return;
1906
+ }
1907
+
1908
+ case 'settings-cancel-transition': {
1909
+ const agentName = this.resolveSettingsAgent(parsed.agent);
1910
+ try {
1911
+ const fw = sharedServer!.app.framework as unknown as {
1912
+ cancelAgentRuntimeSettingsTransition: (n: string) => unknown;
1913
+ };
1914
+ fw.cancelAgentRuntimeSettingsTransition(agentName);
1915
+ } catch (err) {
1916
+ this.send(client, {
1917
+ type: 'error',
1918
+ message: `settings-cancel-transition failed: ${err instanceof Error ? err.message : String(err)}`,
1919
+ });
1920
+ return;
1921
+ }
1922
+ this.broadcastSettingsState(agentName);
1923
+ return;
1924
+ }
1925
+
1766
1926
  case 'request-workspace-mounts': {
1767
1927
  if (parsed.scope && parsed.scope !== 'local') {
1768
1928
  this.routeFleetRequest(client, parsed.scope, 'workspace-mounts',
@@ -2426,6 +2586,125 @@ export class WebUiModule implements Module {
2426
2586
  }
2427
2587
  }
2428
2588
 
2589
+ /** Default to the recipe's primary agent when the client omits a name. */
2590
+ private resolveSettingsAgent(name?: string): string {
2591
+ if (name) return name;
2592
+ const app = sharedServer?.app;
2593
+ return app?.recipe.agent.name ?? app?.framework.getAllAgents()[0]?.name ?? 'agent';
2594
+ }
2595
+
2596
+ /**
2597
+ * Build the settings snapshot. Never throws: this feeds a panel, and a
2598
+ * strategy that isn't hot-configurable is a legitimate state to render
2599
+ * read-only rather than an error to surface.
2600
+ */
2601
+ private buildSettingsState(agentName: string): SettingsStateMessage | null {
2602
+ const app = sharedServer?.app;
2603
+ if (!app) return null;
2604
+ const fw = app.framework as unknown as {
2605
+ getAgentRuntimeSettings?: (n: string) => Record<string, unknown>;
2606
+ getAgent?: (n: string) => unknown;
2607
+ };
2608
+ if (typeof fw.getAgentRuntimeSettings !== 'function') return null;
2609
+
2610
+ let settings: Record<string, unknown>;
2611
+ try {
2612
+ settings = fw.getAgentRuntimeSettings(agentName);
2613
+ } catch {
2614
+ return null;
2615
+ }
2616
+
2617
+ // Which knobs this build can apply live, probed rather than assumed: the
2618
+ // hot set is a property of the ACTIVE strategy, not of the host version.
2619
+ let hotConfigurable = false;
2620
+ let previewAvailable = false;
2621
+ try {
2622
+ const agent = app.framework.getAgent(agentName);
2623
+ const cm = agent?.getContextManager() as unknown as {
2624
+ getHotContextSettings?: () => unknown;
2625
+ previewContext?: unknown;
2626
+ } | undefined;
2627
+ hotConfigurable = !!cm && typeof cm.getHotContextSettings === 'function'
2628
+ && cm.getHotContextSettings() !== null;
2629
+ // Older context-manager builds resolve without previewContext. Report it
2630
+ // so the panel says "preview unavailable on this build" instead of
2631
+ // rendering an empty chart and looking broken.
2632
+ previewAvailable = !!cm && typeof cm.previewContext === 'function';
2633
+ } catch { /* leave both false — read-only panel */ }
2634
+
2635
+ const overrides: string[] = [];
2636
+ try {
2637
+ const agent = app.framework.getAgent(agentName) as unknown as {
2638
+ getRuntimeSettingsOverrides?: () => Record<string, unknown>;
2639
+ } | undefined;
2640
+ const ov = agent?.getRuntimeSettingsOverrides?.() ?? {};
2641
+ for (const [k, val] of Object.entries(ov)) if (val !== undefined) overrides.push(k);
2642
+ } catch { /* informational only */ }
2643
+
2644
+ return {
2645
+ type: 'settings-state',
2646
+ agent: agentName,
2647
+ settings: settings as SettingsStateMessage['settings'],
2648
+ overrides,
2649
+ // contextBudgetTokens is applied by the Agent itself; the other three are
2650
+ // forwarded into the strategy's hot-settings channel, so they need a
2651
+ // hot-configurable strategy to mean anything.
2652
+ hotKeys: hotConfigurable
2653
+ ? ['contextBudgetTokens', 'tailTokens', 'transitionPaceTokens', 'sameRoundThinkTextPolicy']
2654
+ : ['contextBudgetTokens'],
2655
+ hotConfigurable,
2656
+ previewAvailable,
2657
+ };
2658
+ }
2659
+
2660
+ private sendSettingsState(client: ClientState, agentName?: string): void {
2661
+ const msg = this.buildSettingsState(this.resolveSettingsAgent(agentName));
2662
+ if (!msg) {
2663
+ this.send(client, { type: 'error', message: 'runtime settings unavailable on this build' });
2664
+ return;
2665
+ }
2666
+ this.send(client, msg);
2667
+ }
2668
+
2669
+ /** Fan out to every welcomed client — settings are live process state. */
2670
+ private broadcastSettingsState(agentName: string): void {
2671
+ if (!sharedServer?.app) return;
2672
+ const msg = this.buildSettingsState(agentName);
2673
+ if (!msg) return;
2674
+ for (const c of sharedServer.clients.values()) {
2675
+ if (c.welcomed) this.send(c, msg);
2676
+ }
2677
+ }
2678
+
2679
+ /**
2680
+ * Opt-in push notice to the agent that an operator changed its context
2681
+ * settings. OFF by default at the protocol level, because this injects text
2682
+ * into the very context being tuned: it invalidates the KV prefix and is
2683
+ * itself classifier-visible. The zero-cost alternative the agent always has
2684
+ * is to PULL via its `agent_settings` tool.
2685
+ */
2686
+ private notifyAgentOfSettingsChange(agentName: string, kind: 'update' | 'reset'): void {
2687
+ try {
2688
+ const app = sharedServer?.app;
2689
+ if (!app) return;
2690
+ const s = this.buildSettingsState(agentName);
2691
+ const budget = s?.settings.contextBudgetTokens;
2692
+ const tail = s?.settings.tailTokens;
2693
+ const transition = s?.settings.transition;
2694
+ const text = kind === 'reset'
2695
+ ? `[operator] context settings reset to recipe defaults`
2696
+ : `[operator] context settings changed`
2697
+ + (budget !== undefined ? ` — budget ${budget}` : '')
2698
+ + (tail !== undefined ? `, tail ${tail}` : '')
2699
+ + (transition === 'converging' ? ' (converging gradually)' : '');
2700
+ const cm = app.framework.getAgent(agentName)?.getContextManager();
2701
+ cm?.addMessage('Context Manager', [{ type: 'text', text }], { system: true });
2702
+ } catch (err) {
2703
+ // A failed notice must never fail the apply that already succeeded.
2704
+ console.warn('[settings] notify failed (change still applied):', err);
2705
+ }
2706
+ }
2707
+
2429
2708
  private broadcastBranchChanged(): void {
2430
2709
  if (!sharedServer?.app) return;
2431
2710
  const cm = sharedServer?.app.framework.getAllAgents()[0]?.getContextManager();
@@ -391,6 +391,41 @@ export interface McplListMessage {
391
391
  }>;
392
392
  }
393
393
 
394
+ /**
395
+ * Runtime context-settings snapshot.
396
+ *
397
+ * BROADCAST to every welcomed client on change — unlike `mcpl-list`, these are
398
+ * live process state, not a config file, so two operators must not see
399
+ * divergent values.
400
+ */
401
+ export interface SettingsStateMessage {
402
+ type: 'settings-state';
403
+ agent: string;
404
+ /** Live values. `transition` is 'converging' while a paced descent runs. */
405
+ settings: {
406
+ contextBudgetTokens: number;
407
+ tailTokens?: number;
408
+ transitionPaceTokens?: number;
409
+ sameRoundThinkTextPolicy?: string;
410
+ sameRoundThinkTextPolicySource?: string;
411
+ transition: 'stable' | 'converging' | 'blocked';
412
+ /** Why a descent is stuck: pace below floor, or protected context too big. */
413
+ transitionReason?: string;
414
+ };
415
+ /** Keys currently overridden away from the recipe (i.e. persisted). */
416
+ overrides: string[];
417
+ /** Which keys this build can apply live. Everything else needs a restart —
418
+ * the UI must show that split rather than implying all knobs are hot. */
419
+ hotKeys: string[];
420
+ /** False when the strategy is not hot-configurable at all (e.g. passthrough):
421
+ * the panel should go read-only rather than offer controls that will throw. */
422
+ hotConfigurable: boolean;
423
+ /** True when this build's context-manager exposes dry-run preview. Older
424
+ * context-manager versions resolve without it, and the panel must say
425
+ * "preview unavailable" rather than silently showing nothing. */
426
+ previewAvailable: boolean;
427
+ }
428
+
394
429
  /** A page of older history — response to `request-history`, routed only to
395
430
  * the requesting client and correlated by `corrId`. */
396
431
  export interface HistoryPageMessage {
@@ -474,6 +509,7 @@ export type WebUiServerMessage =
474
509
  | QuitConfirmRequiredMessage
475
510
  | LessonsListMessage
476
511
  | McplListMessage
512
+ | SettingsStateMessage
477
513
  | WorkspaceMountsMessage
478
514
  | WorkspaceTreeMessage
479
515
  | WorkspaceFileMessage
@@ -633,6 +669,58 @@ export interface McplSetEnvMessage {
633
669
  env: Record<string, string>;
634
670
  }
635
671
 
672
+ /** Pull the current runtime context settings for an agent, plus which knobs are
673
+ * live-appliable vs restart-only. Response is a `settings-state` envelope. */
674
+ export interface RequestSettingsMessage {
675
+ type: 'request-settings';
676
+ /** Agent name; defaults to the recipe's primary agent. */
677
+ agent?: string;
678
+ }
679
+
680
+ /**
681
+ * Apply runtime context settings live.
682
+ *
683
+ * Semantics that the UI must not misrepresent:
684
+ * - RAISING contextBudgetTokens applies immediately.
685
+ * - LOWERING it starts a PACED convergence (`transition: 'converging'`)
686
+ * rather than folding everything at once; it can be cancelled.
687
+ * - Only these keys are hot. Chunk size, head window, merge threshold and
688
+ * friends are recipe-and-restart only.
689
+ *
690
+ * `persist: false` makes the change ephemeral — live now, gone on restart —
691
+ * which is the mode for operator experiments. Default persists.
692
+ *
693
+ * `notify: true` pushes a notice into the agent's context. OFF by default and
694
+ * deliberately so: the notice is new text in the very context being tuned, so
695
+ * it invalidates the KV prefix and can itself trip a refusal classifier. The
696
+ * agent can always PULL current settings via its own `agent_settings` tool.
697
+ */
698
+ export interface SettingsUpdateMessage {
699
+ type: 'settings-update';
700
+ agent?: string;
701
+ contextBudgetTokens?: number;
702
+ tailTokens?: number;
703
+ transitionPaceTokens?: number;
704
+ persist?: boolean;
705
+ notify?: boolean;
706
+ }
707
+
708
+ /** Revert named settings to their recipe values (all four when omitted). */
709
+ export interface SettingsResetMessage {
710
+ type: 'settings-reset';
711
+ agent?: string;
712
+ keys?: string[];
713
+ persist?: boolean;
714
+ notify?: boolean;
715
+ }
716
+
717
+ /** Abandon an in-flight paced descent, holding the current frontier. */
718
+ export interface SettingsCancelTransitionMessage {
719
+ type: 'settings-cancel-transition';
720
+ agent?: string;
721
+ persist?: boolean;
722
+ }
723
+
636
724
  /** Pull the list of workspace mounts. Response is `workspace-mounts`.
637
725
  * Optional `scope` selects a fleet child instead of the parent. */
638
726
  export interface RequestWorkspaceMountsMessage {
@@ -706,6 +794,10 @@ export type WebUiClientMessage =
706
794
  | McplAddMessage
707
795
  | McplRemoveMessage
708
796
  | McplSetEnvMessage
797
+ | RequestSettingsMessage
798
+ | SettingsUpdateMessage
799
+ | SettingsResetMessage
800
+ | SettingsCancelTransitionMessage
709
801
  | RequestWorkspaceMountsMessage
710
802
  | RequestWorkspaceTreeMessage
711
803
  | RequestWorkspaceFileMessage
@@ -768,6 +860,30 @@ export function isClientMessage(value: unknown): value is WebUiClientMessage {
768
860
  return isValidMcplId(v.id);
769
861
  case 'mcpl-set-env':
770
862
  return isValidMcplId(v.id) && isStringMap(v.env);
863
+ case 'request-settings':
864
+ return isOptionalNonEmptyString(v.agent);
865
+ case 'settings-update': {
866
+ if (!isOptionalNonEmptyString(v.agent)) return false;
867
+ if (!isOptionalBool(v.persist) || !isOptionalBool(v.notify)) return false;
868
+ // Positive-integer-or-absent for each knob. The semantic gate (budget must
869
+ // exceed max response tokens; tail/pace need a hot-configurable strategy)
870
+ // lives in Agent.validateRuntimeSettingsPatch — this only guarantees the
871
+ // shape so that gate gets numbers instead of strings.
872
+ for (const k of ['contextBudgetTokens', 'tailTokens', 'transitionPaceTokens']) {
873
+ if (!isOptionalPositiveInt(v[k])) return false;
874
+ }
875
+ // Require at least one actual knob: an empty patch would throw downstream.
876
+ return v.contextBudgetTokens !== undefined
877
+ || v.tailTokens !== undefined
878
+ || v.transitionPaceTokens !== undefined;
879
+ }
880
+ case 'settings-reset':
881
+ return isOptionalNonEmptyString(v.agent)
882
+ && isOptionalBool(v.persist)
883
+ && isOptionalBool(v.notify)
884
+ && isOptionalStringArray(v.keys);
885
+ case 'settings-cancel-transition':
886
+ return isOptionalNonEmptyString(v.agent) && isOptionalBool(v.persist);
771
887
  case 'request-workspace-mounts':
772
888
  return v.scope === undefined || typeof v.scope === 'string';
773
889
  case 'request-workspace-tree':
@@ -822,3 +938,17 @@ function isOptionalStringArray(v: unknown): v is string[] | undefined {
822
938
  if (!Array.isArray(v)) return false;
823
939
  return v.every((x) => typeof x === 'string');
824
940
  }
941
+
942
+ function isOptionalNonEmptyString(v: unknown): v is string | undefined {
943
+ return v === undefined || isNonEmptyString(v);
944
+ }
945
+
946
+ function isOptionalBool(v: unknown): v is boolean | undefined {
947
+ return v === undefined || typeof v === 'boolean';
948
+ }
949
+
950
+ /** Token counts: safe positive integers only. Rejects strings, NaN, Infinity,
951
+ * fractions and negatives before they reach the settings validator. */
952
+ function isOptionalPositiveInt(v: unknown): v is number | undefined {
953
+ return v === undefined || (typeof v === 'number' && Number.isSafeInteger(v) && v > 0);
954
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Wire validation for the context-settings messages.
3
+ *
4
+ * These messages mutate a LIVE agent's context budget, so malformed payloads
5
+ * must be rejected at the protocol edge rather than reaching
6
+ * Agent.validateRuntimeSettingsPatch as strings/NaN/fractions.
7
+ */
8
+
9
+ import { describe, it, expect } from 'bun:test';
10
+ import { isClientMessage } from '../src/web/protocol.js';
11
+
12
+ describe('settings messages — wire validation', () => {
13
+ it('accepts a minimal budget update', () => {
14
+ expect(isClientMessage({ type: 'settings-update', contextBudgetTokens: 120_000 })).toBe(true);
15
+ });
16
+
17
+ it('accepts tail-only and pace-only updates', () => {
18
+ expect(isClientMessage({ type: 'settings-update', tailTokens: 30_000 })).toBe(true);
19
+ expect(isClientMessage({ type: 'settings-update', transitionPaceTokens: 8_000 })).toBe(true);
20
+ });
21
+
22
+ it('accepts persist/notify flags and an agent name', () => {
23
+ expect(isClientMessage({
24
+ type: 'settings-update',
25
+ contextBudgetTokens: 100_000,
26
+ persist: false,
27
+ notify: true,
28
+ agent: 'mythos',
29
+ })).toBe(true);
30
+ });
31
+
32
+ it('rejects an empty patch — it would throw downstream', () => {
33
+ expect(isClientMessage({ type: 'settings-update' })).toBe(false);
34
+ expect(isClientMessage({ type: 'settings-update', persist: true })).toBe(false);
35
+ });
36
+
37
+ it('rejects non-integer / non-positive / stringly token counts', () => {
38
+ for (const bad of ['100000', 100.5, -1, 0, NaN, Infinity, null, {}]) {
39
+ expect(isClientMessage({ type: 'settings-update', contextBudgetTokens: bad }))
40
+ .toBe(false);
41
+ }
42
+ });
43
+
44
+ it('rejects non-boolean flags and empty agent names', () => {
45
+ expect(isClientMessage({ type: 'settings-update', contextBudgetTokens: 1, persist: 'yes' })).toBe(false);
46
+ expect(isClientMessage({ type: 'settings-update', contextBudgetTokens: 1, notify: 1 })).toBe(false);
47
+ expect(isClientMessage({ type: 'settings-update', contextBudgetTokens: 1, agent: '' })).toBe(false);
48
+ });
49
+
50
+ it('validates reset and cancel shapes', () => {
51
+ expect(isClientMessage({ type: 'settings-reset' })).toBe(true);
52
+ expect(isClientMessage({ type: 'settings-reset', keys: ['contextBudgetTokens'] })).toBe(true);
53
+ expect(isClientMessage({ type: 'settings-reset', keys: 'contextBudgetTokens' })).toBe(false);
54
+ expect(isClientMessage({ type: 'settings-cancel-transition' })).toBe(true);
55
+ expect(isClientMessage({ type: 'settings-cancel-transition', persist: false })).toBe(true);
56
+ expect(isClientMessage({ type: 'settings-cancel-transition', agent: 42 })).toBe(false);
57
+ });
58
+
59
+ it('validates request-settings', () => {
60
+ expect(isClientMessage({ type: 'request-settings' })).toBe(true);
61
+ expect(isClientMessage({ type: 'request-settings', agent: 'mythos' })).toBe(true);
62
+ expect(isClientMessage({ type: 'request-settings', agent: 7 })).toBe(false);
63
+ });
64
+ });
package/web/src/App.tsx CHANGED
@@ -9,6 +9,7 @@ import { StreamPanel, formatStreamEvent, type StreamLine } from './Stream';
9
9
  import { UsagePanel } from './Usage';
10
10
  import { LessonsPanel, type LessonRow } from './Lessons';
11
11
  import { McplPanel, type McplServerRow } from './Mcpl';
12
+ import { SettingsPanel, type SettingsState } from './Settings';
12
13
  import { FilesPanel, FileViewerModal, type Mount, type FlatEntry, type FileViewer } from './Files';
13
14
  import { ContextPanel } from './Context';
14
15
  import { ContextDocument } from './ContextDocument';
@@ -272,7 +273,7 @@ export function App() {
272
273
 
273
274
  /** Right-sidebar tab selection. The Tree is the most-used surface so it's
274
275
  * the default; lessons / mcp / files are operator-driven panels. */
275
- type SidebarTab = 'tree' | 'lessons' | 'mcp' | 'files' | 'context' | 'health';
276
+ type SidebarTab = 'tree' | 'lessons' | 'mcp' | 'files' | 'context' | 'settings' | 'health';
276
277
  const [sidebarTab, setSidebarTab] = createSignal<SidebarTab>('tree');
277
278
  const [mainView, setMainView] = createSignal<'chat' | 'context'>('chat');
278
279
 
@@ -311,6 +312,16 @@ export function App() {
311
312
  wire.send({ type: 'request-mcpl' });
312
313
  };
313
314
 
315
+ /** Context-settings panel state. The server BROADCASTS `settings-state` after
316
+ * every mutation (unlike mcpl-list, which is requester-only), because these
317
+ * are live process values — two operators must not see divergent budgets. */
318
+ const [settingsState, setSettingsState] = createSignal<SettingsState | null>(null);
319
+ const [settingsLoaded, setSettingsLoaded] = createSignal(false);
320
+ const refreshSettings = (): void => {
321
+ setSettingsLoaded(false);
322
+ wire.send({ type: 'request-settings' });
323
+ };
324
+
314
325
  /** Workspace files panel state — mounts list + per-mount tree cache. */
315
326
  const [mounts, setMounts] = createSignal<Mount[]>([]);
316
327
  const [mountsLoaded, setMountsLoaded] = createSignal(false);
@@ -876,6 +887,10 @@ export function App() {
876
887
  setMcplConfigPath(configPath);
877
888
  setMcplServers(servers);
878
889
  },
890
+ setSettings: (state) => {
891
+ setSettingsLoaded(true);
892
+ setSettingsState(state);
893
+ },
879
894
  setMounts: (loaded, moduleLoaded, list) => {
880
895
  setMountsLoaded(loaded);
881
896
  setWorkspaceModuleLoaded(moduleLoaded);
@@ -1174,6 +1189,7 @@ export function App() {
1174
1189
  if (tab === 'lessons' && !lessonsLoaded()) refreshLessons();
1175
1190
  if (tab === 'mcp' && !mcplLoaded()) refreshMcpl();
1176
1191
  if (tab === 'files' && !mountsLoaded()) refreshMounts();
1192
+ if (tab === 'settings' && !settingsLoaded()) refreshSettings();
1177
1193
  }}
1178
1194
  />
1179
1195
  <div class="flex-1 min-h-0">
@@ -1212,6 +1228,17 @@ export function App() {
1212
1228
  onSetEnv={(id, env) => wire.send({ type: 'mcpl-set-env', id, env })}
1213
1229
  />
1214
1230
  </Show>
1231
+ <Show when={sidebarTab() === 'settings'}>
1232
+ <SettingsPanel
1233
+ loaded={settingsLoaded()}
1234
+ state={settingsState()}
1235
+ onRefresh={refreshSettings}
1236
+ onApply={(patch) => wire.send({ type: 'settings-update', ...patch })}
1237
+ onReset={(keys, persist) =>
1238
+ wire.send({ type: 'settings-reset', ...(keys ? { keys } : {}), persist })}
1239
+ onCancelTransition={() => wire.send({ type: 'settings-cancel-transition' })}
1240
+ />
1241
+ </Show>
1215
1242
  <Show when={sidebarTab() === 'files'}>
1216
1243
  <FilesPanel
1217
1244
  loaded={mountsLoaded()}
@@ -1282,6 +1309,8 @@ interface HandlerHooks {
1282
1309
  setLessons: (loaded: boolean, moduleLoaded: boolean, lessons: LessonRow[]) => void;
1283
1310
  /** Apply an mcpl-list response from the server. */
1284
1311
  setMcpl: (configPath: string, servers: McplServerRow[]) => void;
1312
+ /** Apply a settings-state broadcast. */
1313
+ setSettings: (state: SettingsState) => void;
1285
1314
  /** Apply a workspace-mounts response. */
1286
1315
  setMounts: (loaded: boolean, moduleLoaded: boolean, mounts: Mount[]) => void;
1287
1316
  /** Apply a workspace-tree response for one mount. */
@@ -1412,6 +1441,9 @@ function handleServerMessage(
1412
1441
  case 'mcpl-list':
1413
1442
  hooks.setMcpl(msg.configPath, msg.servers);
1414
1443
  return;
1444
+ case 'settings-state':
1445
+ hooks.setSettings(msg as unknown as SettingsState);
1446
+ return;
1415
1447
  case 'workspace-mounts':
1416
1448
  hooks.setMounts(true, msg.loaded, msg.mounts);
1417
1449
  return;
@@ -1942,7 +1974,7 @@ function CommandSuggestions(props: { draft: string; onPick: (cmd: string) => voi
1942
1974
  );
1943
1975
  }
1944
1976
 
1945
- type SidebarTabId = 'tree' | 'lessons' | 'mcp' | 'files' | 'context' | 'health';
1977
+ type SidebarTabId = 'tree' | 'lessons' | 'mcp' | 'files' | 'context' | 'settings' | 'health';
1946
1978
 
1947
1979
  function SidebarTabs(props: {
1948
1980
  current: SidebarTabId;
@@ -1954,6 +1986,7 @@ function SidebarTabs(props: {
1954
1986
  { id: 'mcp', label: 'MCP', title: 'MCPL servers' },
1955
1987
  { id: 'files', label: 'Files', title: 'Workspace mounts + files' },
1956
1988
  { id: 'context', label: 'Context', title: 'Compiled context makeup' },
1989
+ { id: 'settings', label: 'Settings', title: 'Live context budget / tail, with preview' },
1957
1990
  { id: 'health', label: 'Health', title: 'Runtime settings, failures, quarantine' },
1958
1991
  ];
1959
1992
  return (
@@ -0,0 +1,434 @@
1
+ /**
2
+ * Context settings panel — live control of the agent's compile window.
3
+ *
4
+ * Replaces the stop → edit the `framework/state` Chronicle slot → start dance.
5
+ * Three things this panel is deliberately honest about, because getting any of
6
+ * them wrong would mislead an operator into an outage:
7
+ *
8
+ * 1. RAISING the budget applies at once; LOWERING starts a PACED convergence
9
+ * that can sit in `converging` for many turns, and can be cancelled. Apply
10
+ * is not "done" the moment it returns.
11
+ * 2. Only a few keys are hot. Chunk size, head window, merge threshold and
12
+ * friends are recipe-and-restart-only — shown, but not offered as controls.
13
+ * 3. Preview requires a context-manager build with dry-run support. When the
14
+ * resolved build lacks it we say so, rather than rendering an empty result
15
+ * that looks like "nothing would change".
16
+ */
17
+
18
+ import { createSignal, Show, For, onCleanup } from 'solid-js';
19
+
20
+ export interface SettingsSnapshot {
21
+ contextBudgetTokens: number;
22
+ tailTokens?: number;
23
+ transitionPaceTokens?: number;
24
+ sameRoundThinkTextPolicy?: string;
25
+ sameRoundThinkTextPolicySource?: string;
26
+ transition: 'stable' | 'converging' | 'blocked';
27
+ transitionReason?: string;
28
+ }
29
+
30
+ export interface SettingsState {
31
+ agent: string;
32
+ settings: SettingsSnapshot;
33
+ overrides: string[];
34
+ hotKeys: string[];
35
+ hotConfigurable: boolean;
36
+ previewAvailable: boolean;
37
+ }
38
+
39
+ interface PreviewResult {
40
+ finalTokens: number;
41
+ budgetTokens: number;
42
+ fits: boolean;
43
+ exhausted: boolean;
44
+ headTokens: number;
45
+ tailTokens: number;
46
+ middleTokens: number;
47
+ middleChunkCount: number;
48
+ deepestLevel: number;
49
+ resolutions: Record<string, number>;
50
+ appliedCount: number;
51
+ producedCount: number;
52
+ }
53
+
54
+ /** Settings the strategy reads only at construction — surfaced so an operator
55
+ * can see them without being invited to "change" them from here. */
56
+ const RESTART_ONLY = [
57
+ 'targetChunkTokens',
58
+ 'headWindowTokens',
59
+ 'mergeThreshold',
60
+ 'foldingStrategy',
61
+ 'maxSpeculativeL1s',
62
+ 'adaptiveResolution',
63
+ 'compressionSlackRatio',
64
+ ];
65
+
66
+ const TRANSITION_HELP: Record<string, string> = {
67
+ transition_pace_too_small:
68
+ 'the per-turn pace is below the floor — raise transitionPaceTokens or the descent cannot progress',
69
+ protected_context_exceeds_target:
70
+ 'head + tail alone exceed the target — the verbatim window must shrink before this budget is reachable',
71
+ };
72
+
73
+ export function SettingsPanel(props: {
74
+ loaded: boolean;
75
+ state: SettingsState | null;
76
+ onRefresh(): void;
77
+ onApply(patch: {
78
+ contextBudgetTokens?: number;
79
+ tailTokens?: number;
80
+ transitionPaceTokens?: number;
81
+ persist: boolean;
82
+ notify: boolean;
83
+ }): void;
84
+ onReset(keys: string[] | undefined, persist: boolean): void;
85
+ onCancelTransition(): void;
86
+ }) {
87
+ const [budget, setBudget] = createSignal<string>('');
88
+ const [tail, setTail] = createSignal<string>('');
89
+ const [pace, setPace] = createSignal<string>('');
90
+ const [persist, setPersist] = createSignal(true);
91
+ const [notify, setNotify] = createSignal(false);
92
+
93
+ const [preview, setPreview] = createSignal<PreviewResult | null>(null);
94
+ const [previewErr, setPreviewErr] = createSignal<string | null>(null);
95
+ const [previewing, setPreviewing] = createSignal(false);
96
+
97
+ let debounce: number | undefined;
98
+ onCleanup(() => { if (debounce) window.clearTimeout(debounce); });
99
+
100
+ const s = () => props.state?.settings;
101
+ const isHot = (k: string) => props.state?.hotKeys.includes(k) ?? false;
102
+
103
+ /** Prefill the inputs from live values so the operator edits from reality. */
104
+ const syncFromLive = () => {
105
+ const cur = s();
106
+ if (!cur) return;
107
+ setBudget(String(cur.contextBudgetTokens));
108
+ setTail(cur.tailTokens !== undefined ? String(cur.tailTokens) : '');
109
+ setPace(cur.transitionPaceTokens !== undefined ? String(cur.transitionPaceTokens) : '');
110
+ };
111
+
112
+ const num = (v: string): number | undefined => {
113
+ if (v.trim() === '') return undefined;
114
+ const n = Number(v);
115
+ return Number.isSafeInteger(n) && n > 0 ? n : undefined;
116
+ };
117
+
118
+ /** Debounced: an operator dragging a value should not fire a compile per keystroke.
119
+ * The request itself is non-committing, but it is still real CPU on a live agent. */
120
+ const runPreview = () => {
121
+ if (!props.state?.previewAvailable) return;
122
+ const b = num(budget());
123
+ if (b === undefined) { setPreview(null); setPreviewErr(null); return; }
124
+ if (debounce) window.clearTimeout(debounce);
125
+ debounce = window.setTimeout(async () => {
126
+ setPreviewing(true);
127
+ setPreviewErr(null);
128
+ try {
129
+ const qs = new URLSearchParams({ budget: String(b), agent: props.state!.agent });
130
+ const t = num(tail());
131
+ if (t !== undefined) qs.set('tail', String(t));
132
+ const res = await fetch(`/debug/context/preview?${qs}`, { credentials: 'same-origin' });
133
+ const body = await res.json();
134
+ if (!res.ok) {
135
+ setPreview(null);
136
+ setPreviewErr(body?.error ?? `HTTP ${res.status}`);
137
+ return;
138
+ }
139
+ setPreview(body.preview as PreviewResult);
140
+ } catch (e) {
141
+ setPreview(null);
142
+ setPreviewErr(e instanceof Error ? e.message : String(e));
143
+ } finally {
144
+ setPreviewing(false);
145
+ }
146
+ }, 350) as unknown as number;
147
+ };
148
+
149
+ const apply = () => {
150
+ const patch: Record<string, number> = {};
151
+ const b = num(budget());
152
+ const t = num(tail());
153
+ const p = num(pace());
154
+ const cur = s();
155
+ if (b !== undefined && b !== cur?.contextBudgetTokens) patch.contextBudgetTokens = b;
156
+ if (t !== undefined && t !== cur?.tailTokens) patch.tailTokens = t;
157
+ if (p !== undefined && p !== cur?.transitionPaceTokens) patch.transitionPaceTokens = p;
158
+ if (Object.keys(patch).length === 0) return;
159
+ props.onApply({ ...patch, persist: persist(), notify: notify() });
160
+ };
161
+
162
+ const lowering = () => {
163
+ const b = num(budget());
164
+ const cur = s();
165
+ return b !== undefined && cur !== undefined && b < cur.contextBudgetTokens;
166
+ };
167
+
168
+ const dirty = () => {
169
+ const cur = s();
170
+ if (!cur) return false;
171
+ const b = num(budget()), t = num(tail()), p = num(pace());
172
+ return (b !== undefined && b !== cur.contextBudgetTokens)
173
+ || (t !== undefined && t !== cur.tailTokens)
174
+ || (p !== undefined && p !== cur.transitionPaceTokens);
175
+ };
176
+
177
+ return (
178
+ <div class="h-full overflow-y-auto px-3 py-2 text-xs">
179
+ <div class="flex items-center gap-2 mb-2">
180
+ <span class="text-neutral-500 uppercase tracking-wider text-[10px] font-semibold">
181
+ context settings
182
+ </span>
183
+ <Show when={props.state}>
184
+ <span class="text-neutral-600 text-[10px] font-mono">{props.state!.agent}</span>
185
+ </Show>
186
+ <button
187
+ type="button"
188
+ class="ml-auto px-2 py-0.5 text-[10px] bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded font-mono"
189
+ onClick={() => { props.onRefresh(); setTimeout(syncFromLive, 150); }}
190
+ >
191
+ refresh
192
+ </button>
193
+ </div>
194
+
195
+ <Show when={!props.loaded}>
196
+ <div class="text-neutral-600 italic">Loading…</div>
197
+ </Show>
198
+
199
+ <Show when={props.loaded && !props.state}>
200
+ <div class="text-amber-400/80">Runtime settings unavailable on this build.</div>
201
+ </Show>
202
+
203
+ <Show when={props.state}>
204
+ {/* ---- transition status: the thing an operator most needs to see ---- */}
205
+ <Show when={s()!.transition !== 'stable'}>
206
+ <div
207
+ class={`mb-2 px-2 py-1.5 rounded border text-[11px] ${
208
+ s()!.transition === 'blocked'
209
+ ? 'bg-red-950/40 border-red-800 text-red-300'
210
+ : 'bg-amber-950/40 border-amber-800 text-amber-200'
211
+ }`}
212
+ >
213
+ <div class="font-semibold">
214
+ {s()!.transition === 'blocked' ? 'descent BLOCKED' : 'descent converging'}
215
+ </div>
216
+ <Show when={s()!.transitionReason}>
217
+ <div class="mt-0.5 text-[10px] opacity-90">
218
+ {TRANSITION_HELP[s()!.transitionReason!] ?? s()!.transitionReason}
219
+ </div>
220
+ </Show>
221
+ <button
222
+ type="button"
223
+ class="mt-1 px-2 py-0.5 text-[10px] bg-neutral-800 hover:bg-neutral-700 text-neutral-200 rounded font-mono"
224
+ onClick={() => props.onCancelTransition()}
225
+ >
226
+ cancel transition (hold current frontier)
227
+ </button>
228
+ </div>
229
+ </Show>
230
+
231
+ <Show when={!props.state!.hotConfigurable}>
232
+ <div class="mb-2 px-2 py-1 rounded bg-neutral-900 border border-neutral-800 text-[10px] text-neutral-400">
233
+ This strategy is not hot-configurable — only the budget can be changed live.
234
+ </div>
235
+ </Show>
236
+
237
+ {/* ---- live values ---- */}
238
+ <table class="w-full mb-3 font-mono text-[11px]">
239
+ <tbody>
240
+ <For each={[
241
+ ['contextBudgetTokens', s()!.contextBudgetTokens],
242
+ ['tailTokens', s()!.tailTokens],
243
+ ['transitionPaceTokens', s()!.transitionPaceTokens],
244
+ ['sameRoundThinkTextPolicy', s()!.sameRoundThinkTextPolicy],
245
+ ] as Array<[string, unknown]>}>
246
+ {([k, v]) => (
247
+ <tr>
248
+ <td class="text-neutral-500 pr-2">{k}</td>
249
+ <td class="text-neutral-200 text-right tabular-nums">
250
+ {v === undefined ? '–' : String(v)}
251
+ </td>
252
+ <td class="pl-2 w-16">
253
+ <Show when={props.state!.overrides.includes(k)}>
254
+ <span class="text-[9px] px-1 rounded bg-cyan-900/50 text-cyan-300">override</span>
255
+ </Show>
256
+ </td>
257
+ </tr>
258
+ )}
259
+ </For>
260
+ </tbody>
261
+ </table>
262
+
263
+ {/* ---- editors ---- */}
264
+ <div class="space-y-1.5 mb-2">
265
+ <For each={[
266
+ ['budget', 'contextBudgetTokens', budget, setBudget] as const,
267
+ ['tail', 'tailTokens', tail, setTail] as const,
268
+ ['pace', 'transitionPaceTokens', pace, setPace] as const,
269
+ ]}>
270
+ {([label, key, get, set]) => (
271
+ <label class="flex items-center gap-2">
272
+ <span class="text-neutral-500 w-12">{label}</span>
273
+ <input
274
+ type="number"
275
+ disabled={!isHot(key)}
276
+ value={get()}
277
+ placeholder={isHot(key) ? 'tokens' : 'restart only'}
278
+ onInput={(e) => { set(e.currentTarget.value); runPreview(); }}
279
+ class="flex-1 bg-neutral-900 border border-neutral-700 rounded px-1.5 py-0.5
280
+ font-mono text-[11px] text-neutral-100 disabled:opacity-40"
281
+ />
282
+ </label>
283
+ )}
284
+ </For>
285
+ </div>
286
+
287
+ <div class="flex items-center gap-3 mb-2 text-[10px] text-neutral-400">
288
+ <label class="flex items-center gap-1">
289
+ <input type="checkbox" checked={persist()} onChange={(e) => setPersist(e.currentTarget.checked)} />
290
+ persist
291
+ </label>
292
+ <label class="flex items-center gap-1" title="Injects a notice into the agent's context. This perturbs the very context being tuned — it invalidates the KV prefix and is classifier-visible. The agent can always read settings itself via agent_settings.">
293
+ <input type="checkbox" checked={notify()} onChange={(e) => setNotify(e.currentTarget.checked)} />
294
+ notify agent
295
+ </label>
296
+ </div>
297
+ <div class="text-[10px] text-neutral-600 mb-2 leading-relaxed">
298
+ <Show when={!persist()}>
299
+ <div>ephemeral — live now, reverts on restart.</div>
300
+ </Show>
301
+ <Show when={lowering()}>
302
+ <div class="text-amber-500/90">
303
+ lowering the budget converges gradually, not instantly — it will report
304
+ <span class="font-mono"> converging</span> until it settles.
305
+ </div>
306
+ </Show>
307
+ </div>
308
+
309
+ <div class="flex items-center gap-2 mb-3">
310
+ <button
311
+ type="button"
312
+ disabled={!dirty()}
313
+ class="px-2 py-0.5 text-[10px] rounded font-mono bg-cyan-900/50 hover:bg-cyan-900/70
314
+ text-cyan-200 disabled:opacity-30 disabled:hover:bg-cyan-900/50"
315
+ onClick={apply}
316
+ >
317
+ apply
318
+ </button>
319
+ <button
320
+ type="button"
321
+ class="px-2 py-0.5 text-[10px] bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded font-mono"
322
+ onClick={() => { props.onReset(undefined, persist()); setTimeout(syncFromLive, 200); }}
323
+ >
324
+ reset to recipe
325
+ </button>
326
+ <button
327
+ type="button"
328
+ class="px-2 py-0.5 text-[10px] bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded font-mono"
329
+ onClick={syncFromLive}
330
+ >
331
+ revert edits
332
+ </button>
333
+ </div>
334
+
335
+ {/* ---- preview ---- */}
336
+ <div class="border-t border-neutral-800 pt-2">
337
+ <div class="flex items-center gap-2 mb-1">
338
+ <span class="text-neutral-500 uppercase tracking-wider text-[10px] font-semibold">
339
+ preview
340
+ </span>
341
+ <Show when={previewing()}>
342
+ <span class="text-neutral-600 text-[10px]">computing…</span>
343
+ </Show>
344
+ </div>
345
+
346
+ <Show when={!props.state!.previewAvailable}>
347
+ <div class="text-[10px] text-neutral-500 leading-relaxed">
348
+ Preview unavailable on this build — the resolved context-manager has no
349
+ dry-run support. Apply still works; you just won't see the plan first.
350
+ </div>
351
+ </Show>
352
+
353
+ <Show when={props.state!.previewAvailable && previewErr()}>
354
+ <div class="text-[10px] text-amber-400/90">{previewErr()}</div>
355
+ </Show>
356
+
357
+ <Show when={preview()}>
358
+ {(p) => (
359
+ <div>
360
+ <div
361
+ class={`px-2 py-1 rounded mb-1.5 text-[11px] border ${
362
+ p().fits
363
+ ? 'bg-emerald-950/40 border-emerald-800 text-emerald-300'
364
+ : 'bg-red-950/40 border-red-800 text-red-300'
365
+ }`}
366
+ >
367
+ {p().fits
368
+ ? `fits — ${p().finalTokens.toLocaleString()} of ${p().budgetTokens.toLocaleString()} tok`
369
+ : `DOES NOT FIT — ${p().finalTokens.toLocaleString()} tok vs ${p().budgetTokens.toLocaleString()} budget`}
370
+ <Show when={!p().fits && p().exhausted}>
371
+ <div class="text-[10px] opacity-90 mt-0.5">
372
+ picker exhausted: nothing further can be folded. Applying this would
373
+ hard-fail the compile.
374
+ </div>
375
+ </Show>
376
+ </div>
377
+
378
+ <table class="w-full font-mono text-[10px]">
379
+ <tbody>
380
+ <For each={[
381
+ ['head (verbatim)', p().headTokens],
382
+ ['tail (verbatim)', p().tailTokens],
383
+ ['middle (foldable)', p().middleTokens],
384
+ ['middle chunks', p().middleChunkCount],
385
+ ['deepest fold level', `L${p().deepestLevel}`],
386
+ ['folds applied', p().appliedCount],
387
+ ] as Array<[string, unknown]>}>
388
+ {([k, v]) => (
389
+ <tr>
390
+ <td class="text-neutral-500 pr-2">{k}</td>
391
+ <td class="text-neutral-300 text-right tabular-nums">
392
+ {typeof v === 'number' ? v.toLocaleString() : String(v)}
393
+ </td>
394
+ </tr>
395
+ )}
396
+ </For>
397
+ <tr>
398
+ <td class="text-neutral-500 pr-2">summaries to produce</td>
399
+ <td
400
+ class={`text-right tabular-nums ${
401
+ p().producedCount > 0 ? 'text-amber-400' : 'text-neutral-300'
402
+ }`}
403
+ >
404
+ {p().producedCount}
405
+ </td>
406
+ </tr>
407
+ </tbody>
408
+ </table>
409
+ <Show when={p().producedCount > 0}>
410
+ <div class="text-[10px] text-amber-500/80 mt-1 leading-relaxed">
411
+ {p().producedCount} summar{p().producedCount === 1 ? 'y' : 'ies'} do not exist
412
+ yet — reaching this layout costs that many compression calls first.
413
+ </div>
414
+ </Show>
415
+ </div>
416
+ )}
417
+ </Show>
418
+ </div>
419
+
420
+ {/* ---- restart-only knobs, shown but not offered ---- */}
421
+ <div class="border-t border-neutral-800 mt-3 pt-2">
422
+ <div class="text-neutral-500 uppercase tracking-wider text-[10px] font-semibold mb-1">
423
+ restart only
424
+ </div>
425
+ <div class="text-[10px] text-neutral-600 leading-relaxed">
426
+ These come from the recipe and are read at construction — changing them needs a
427
+ restart, not this panel:
428
+ <span class="font-mono text-neutral-500"> {RESTART_ONLY.join(', ')}</span>
429
+ </div>
430
+ </div>
431
+ </Show>
432
+ </div>
433
+ );
434
+ }