dsh-switchman 1.10.3 → 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,23 +12,29 @@
12
12
  * - agent/status idle hook: the only place fold decisions run (design §4:
13
13
  * fold at the idle boundary, never mid-stream).
14
14
  * - ctx_trim_fold / ctx_trim_status tools: the L1 advisory surface — the
15
- * session model may fold ranges itself and inspect trim state.
16
- * - systemPrompt head section (byte-stable protocol text) + live context
17
- * line (bucketed view tokens / fold count / growth — byte-stable between
18
- * 5k-token steps so the serialized tail stops churning).
15
+ * session model may fold ranges itself and inspect compressible ranges
16
+ * (true sizes only — NEVER a fill percentage or water level; bili lesson
17
+ * #2, docs/ladder-fold.md).
18
+ *
19
+ * MODEL-VISIBILITY INVARIANT: the trim layer registers NO systemPrompt
20
+ * context line. Water levels, view-token estimates, growth numbers, and
21
+ * fold counts are engine internals; the model sees only the byte-stable
22
+ * protocol head and (on demand) the true-size recommendation list. Human
23
+ * diagnostics live in decisions.log.
19
24
  *
20
25
  * Config fields (host/config.js, all volatile): trimEnabled, trimMode
21
26
  * ("off"|"advisory"|"auto"), trimGrowthTokens, trimIncrementTokens,
22
27
  * trimPressurePercent, trimEmergencyPercent, trimWindowTokens,
23
- * trimProtectedTail,
24
- * trimMaxFoldsPerHour, trimSideModel, trimStateDir.
28
+ * trimProtectedTail, trimMaxFoldsPerHour, trimSideModel, trimFoldFallbackModel, trimStateDir,
29
+ * trimLadderEnabled, trimLadderT2Tokens, trimLadderT2MinBlocks,
30
+ * trimLadderT3Tokens, trimLadderT3MinBlocks.
25
31
  */
26
32
 
27
33
  import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
28
34
  import { join } from "node:path";
29
- import { buildView, estimateTokensOf, injectView, ledgerIsStale, rangeIsSegmentAligned, refOf, refPositions } from "./view.js";
35
+ import { buildView, estimateTokensOf, injectView, ledgerIsStale, messageTokens, rangeIsSegmentAligned, refOf, refPositions } from "./view.js";
30
36
  import { computeFoldableRange, evaluateTrigger, EMERGENCY_COOLDOWN_MS } from "./trigger.js";
31
- import { mechanicalSummary, renderMaterial, summarizeWithModel } from "./summarizer.js";
37
+ import { LADDER_SYSTEM_PROMPT, mechanicalSummary, renderLadderMaterial, renderMaterial, summarizeWithModel } from "./summarizer.js";
32
38
  import { createStore } from "./state.js";
33
39
  import { createMetrics, extractUsage, promptTokensOf, wrapStreamForMetrics } from "./metrics.js";
34
40
  import { forkLinkOf, loadForkLinks, recordForkLink, resolveInheritance, INHERIT_WINDOW_MS } from "./fork.js";
@@ -42,13 +48,16 @@ import { childSessionIdOf } from "../subagent-model.js";
42
48
  * agent/status and assembleContextFor's context.agent are both the object
43
49
  * form). Mirrors the host/context-watch.js:122 precedent. Returns null when
44
50
  * nothing usable is present. */
45
- // ---- cross-layer view registry (meter duality, design §8) ------------------
46
- // Latest outbound-view token estimate per session, refreshed on EVERY
47
- // llm/stream call. host/context-watch.js prefers this over the token meter's
48
- // stored tally while it is fresh, so the watermark tracks what the model
49
- // actually sees after billion-context folding instead of over-triggering
50
- // handovers off a stale stored measure.
51
+ // ---- cross-layer meter registries (meter duality, design §8) ---------------
52
+ // Per session: the latest outbound-view token estimate plus the last sampled
53
+ // provider prompt, refreshed on every ACCEPTED main llm/stream call (the
54
+ // side-dispatch guard keeps auxiliary calls out of both). host/context-watch.js
55
+ // reads the wire-domain bridge over these (recentTrimPromptTokensOf:
56
+ // max(view, wire)) so the watermark meters on the same boundary as the trim
57
+ // pressure tiers; the wire-truth gate decides when neither registry may drive
58
+ // it and the stored tally takes over.
51
59
  const trimViewBySession = new Map(); // sessionId -> { viewTokens, at, inject, lastNoneAt }
60
+ const trimWireBySession = new Map(); // sessionId -> { promptTokens, at } — last sampled provider prompt of an accepted MAIN dispatch (side calls never write; a tiny inherited-id usage sample must not lower the watermark's wire floor)
52
61
  /** Freshness TTL for the in-memory view/injection registry (watermark and
53
62
  * banner display only). NEVER a fold gate: gating folds on "did the surface
54
63
  * channel ship recently" deadlocks resumed sessions (2026-11-05). The fold
@@ -72,33 +81,88 @@ const SIDE_DISPATCH_SKIP_BUDGET = 2;
72
81
  * accepted main dispatch). */
73
82
  const sideDispatchSkipsBySession = new Map();
74
83
 
84
+ /** Shared wire-truth gate (docs/context-trim.md): the view-registry entry for
85
+ * one session when the recent dispatch stream provably carried the view —
86
+ * null when missing, stale, injected "none", or inside the sticky lastNoneAt
87
+ * window (mixed sessions exist — one real session logged 46 none + 14
88
+ * swapped). Observed 2026-11-04: a view that never reaches the wire must not
89
+ * drive the watermark (view ~80k vs a 176k wire while the force tier stayed
90
+ * silent and the auto-handover never fired). Both watermark accessors read
91
+ * through this gate; itself fail-open. */
92
+ function gatedViewEntry(sessionId, maxAgeMs) {
93
+ if (typeof sessionId !== "string" || sessionId === "") return null;
94
+ const entry = trimViewBySession.get(sessionId);
95
+ if (entry === undefined) return null;
96
+ if (Date.now() - entry.at > maxAgeMs) return null;
97
+ if (entry.inject === "none") return null;
98
+ if (Number.isFinite(entry.lastNoneAt) && entry.lastNoneAt > 0 && Date.now() - entry.lastNoneAt < maxAgeMs) return null;
99
+ return entry;
100
+ }
101
+
75
102
  /** Most recent outbound-view token estimate for a session, or null when
76
- * missing, non-positive, older than maxAgeMs — or when the recent dispatch
77
- * stream could not carry the view (injected "none": frozen options AND
78
- * messages, so the model still receives the raw history and the provider
79
- * tally is the only truthful meter). The gate is deliberately sticky: a
80
- * single mutable dispatch right after frozen ones re-arms maybeFold, but
81
- * the WATERMARK keeps falling back to the tally until no "none" occurred
82
- * within the TTL window (mixed sessions exist — one real session logged 46
83
- * none + 14 swapped). Read by the watermark layer (meter duality); itself
84
- * fail-open. */
103
+ * missing, non-positive, or gate-rejected. The bare-view half of the meter
104
+ * duality bridge — kept for diagnostics and as the gate primitive; the
105
+ * watermark layer reads the wire-domain accessor below. */
85
106
  export function recentTrimViewTokensOf(sessionId, maxAgeMs = TRIM_VIEW_MAX_AGE_MS) {
86
107
  try {
87
- if (typeof sessionId !== "string" || sessionId === "") return null;
88
- const entry = trimViewBySession.get(sessionId);
89
- if (entry === undefined) return null;
90
- if (Date.now() - entry.at > maxAgeMs) return null;
91
- // Wire-truth gate: a view that never reaches the wire must not drive
92
- // the watermark (observed 2026-11-04: view ~80k vs a 176k wire while
93
- // the force tier stayed silent and the auto-handover never fired).
94
- if (entry.inject === "none") return null;
95
- if (Number.isFinite(entry.lastNoneAt) && entry.lastNoneAt > 0 && Date.now() - entry.lastNoneAt < maxAgeMs) return null;
96
- return Number.isFinite(entry.viewTokens) && entry.viewTokens > 0 ? entry.viewTokens : null;
108
+ const entry = gatedViewEntry(sessionId, maxAgeMs);
109
+ return entry !== null && Number.isFinite(entry.viewTokens) && entry.viewTokens > 0 ? entry.viewTokens : null;
97
110
  } catch {
98
111
  return null;
99
112
  }
100
113
  }
101
114
 
115
+ /** Meter-duality bridge, wire domain (design §8): the same number the trim
116
+ * pressure tiers evaluate in maybeFold — Math.max(outbound view estimate,
117
+ * the last sampled provider prompt) — or null when the gate rejects or
118
+ * neither component is fresh. The watermark layer reads THIS (not the bare
119
+ * view) so its soft/hard/force tiers and the read budget sit on the same
120
+ * boundary as the fold engine: the view estimate never counts the
121
+ * system/tool sections (a session-shape-dependent mass — live decision-log
122
+ * pairs drift −5k…+6k between view and wire in steady state), so a
123
+ * view-only watermark tightens every protective tier late by exactly that
124
+ * mass, and the wmHardTokens-95k ≈ trim-pressure-97.5k adjacency (65%
125
+ * of the 150k window) is silently defeated. The wire half comes from
126
+ * accepted MAIN dispatches only (noteTrimWireTokens below). Same sticky
127
+ * gate as the view bridge;
128
+ * fail-open — null means the caller falls back to the stored tally. */
129
+ export function recentTrimPromptTokensOf(sessionId, maxAgeMs = TRIM_VIEW_MAX_AGE_MS) {
130
+ try {
131
+ const entry = gatedViewEntry(sessionId, maxAgeMs);
132
+ if (entry === null) return null;
133
+ let best = 0;
134
+ if (Number.isFinite(entry.viewTokens) && entry.viewTokens > 0) best = entry.viewTokens;
135
+ const wire = trimWireBySession.get(sessionId);
136
+ if (wire !== undefined && Date.now() - wire.at <= maxAgeMs && Number.isFinite(wire.promptTokens) && wire.promptTokens > best) best = wire.promptTokens;
137
+ return best > 0 ? best : null;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ /** Record the provider prompt size (uncached + cacheRead + cacheWrite) of one
144
+ * usage sample from an ACCEPTED main dispatch. Side dispatches (fold-span
145
+ * re-sends, title generation, web-search summaries) sample usage on the same
146
+ * session id but must never reach this registry: their tiny prompts would
147
+ * LOWER the watermark's wire floor below the main history's real prompt —
148
+ * exactly the pollution the side-dispatch guard keeps out of the view
149
+ * registry. Fail-open, bounded like the view registry. */
150
+ function noteTrimWireTokens(sessionId, usage) {
151
+ try {
152
+ if (typeof sessionId !== "string" || sessionId === "") return;
153
+ const promptTokens = promptTokensOf({
154
+ lastInputTokens: usage.inputTokens,
155
+ cachedTokens: usage.cachedTokens,
156
+ cacheWriteTokens: usage.cacheWriteTokens,
157
+ });
158
+ if (promptTokens === null) return;
159
+ trimWireBySession.set(sessionId, { promptTokens, at: Date.now() });
160
+ if (trimWireBySession.size > 200) trimWireBySession.delete(trimWireBySession.keys().next().value);
161
+ } catch {
162
+ /* observability must never throw */
163
+ }
164
+ }
165
+
102
166
  /** Injection outcome of the last dispatch for one session ("in-place" |
103
167
  * "swapped" | "none"), or null when unknown or stale. Read by maybeFold:
104
168
  * when the wire cannot carry the view, folding cannot relieve anything and
@@ -124,14 +188,6 @@ export function agentSessionIdOf(agent) {
124
188
  return null;
125
189
  }
126
190
 
127
- /** Display label for the banner: object → name, string → itself, else the
128
- * session id (never prints [object Object]). */
129
- export function agentLabelOf(agent) {
130
- if (typeof agent === "string") return agent;
131
- if (agent !== null && typeof agent === "object" && typeof agent.name === "string" && agent.name !== "") return agent.name;
132
- return agentSessionIdOf(agent);
133
- }
134
-
135
191
  // ---- config field readers (fail-open defaults) -------------------------------
136
192
 
137
193
  function readBool(config, key, fallback) {
@@ -189,12 +245,14 @@ function readRoute(config, key) {
189
245
  // ---- prompt texts -------------------------------------------------------------
190
246
 
191
247
  /** Byte-stable head section: the trim protocol (digit-free, like the
192
- * watermark protocol, so it stays out of KV invariants' way). */
248
+ * watermark protocol, so it stays out of KV invariants' way). States the
249
+ * number-free disclosure policy explicitly — the model never receives
250
+ * water levels or progress numbers from this engine. */
193
251
  export const TRIM_PROTOCOL_TEXT = [
194
- "[SWITCHMAN:TRIM] This session has automatic context trimming.",
252
+ "[SWITCHMAN:TRIM] This session has automatic context trimming. The engine folds consumed ranges itself and never reports sizes, percentages, or progress to you — no action or pacing is expected of you on that front.",
195
253
  "- Old message ranges may be replaced by ONE synthetic [SWITCHMAN:TRIM] summary message; treat those summaries as faithful compressions, and message refs inside them as historical anchors.",
196
254
  "- NEVER contradict or re-derive content a summary already settled; build on it.",
197
- "- When context grows large, you may fold a contiguous range yourself with the ctx_trim_fold tool (give firstId/lastId message refs and a summary following the compression rules); inspect state with ctx_trim_status.",
255
+ "- You may fold a contiguous range yourself with the ctx_trim_fold tool (give firstId/lastId message refs and a summary following the compression rules); ctx_trim_status lists the compressible ranges with their true sizes.",
198
256
  "- Tool-call pairs (an assistant tool call and its results) must always be folded together or not at all.",
199
257
  ].join("\n");
200
258
 
@@ -209,37 +267,6 @@ export function effectiveWindowTokens(resolved, cap) {
209
267
  return Math.min(a, b);
210
268
  }
211
269
 
212
- /** Live context line renderer (pure; exported for tests). Silent in "off"
213
- * mode and whenever nothing has been measured yet. wireRaw marks a session
214
- * whose last dispatch could not carry the fold view (frozen request) — the
215
- * displayed view is then meter-only and the watermark governs off the
216
- * provider tally. */
217
- export function renderTrimContextLine(agent, { enabled, mode, viewTokens, windowTokens, folds, growthTokens, lastModel, wireRaw = false }) {
218
- if (!enabled) return "";
219
- if (mode === "off") return "";
220
- if (agent === null || agent === undefined || agent === "") return "";
221
- const hasView = Number.isFinite(viewTokens) && viewTokens > 0;
222
- const hasFolds = Number.isFinite(folds) && folds > 0;
223
- if (!hasView && !hasFolds) return "";
224
- const kilo = (n) => `${Math.round(n / 1000)}k`;
225
- const parts = [];
226
- if (hasView) {
227
- let usage = "";
228
- if (Number.isFinite(windowTokens) && windowTokens > 0) usage = ` (${Math.round((viewTokens / windowTokens) * 100)}% of window ${kilo(windowTokens)})`;
229
- else usage = " (window unknown — pressure tiers off)";
230
- parts.push(`view ~${kilo(viewTokens)} tokens${usage}`);
231
- if (wireRaw) parts.push("wire=raw (folds not shipping; watermark reads the tally)");
232
- }
233
- if (hasFolds) {
234
- parts.push(`folds ${folds}`);
235
- if (Number.isFinite(growthTokens)) parts.push(`growth ~${kilo(growthTokens)} since last fold`);
236
- }
237
- if (lastModel !== null && typeof lastModel === "object" && typeof lastModel.model === "string" && lastModel.model !== "") parts.push(`model ${lastModel.model}`);
238
- if (parts.length === 0) return "";
239
- const modeNote = mode === "advisory" ? "advisory: fold via ctx_trim_fold when context feels heavy" : "auto folding active";
240
- return `[SWITCHMAN:TRIM] agent ${agent}: ${parts.join(", ")}; ${modeNote}.\n`;
241
- }
242
-
243
270
  // ---- layer ---------------------------------------------------------------------
244
271
 
245
272
  /** Register the context-trim layer. Wired from the plugin root after
@@ -254,6 +281,28 @@ export function applyContextTrim(ctx, config) {
254
281
  }
255
282
  };
256
283
 
284
+ // Reload/teardown marker: HMR disposes and remounts this section on every
285
+ // plugin edit (the profile's node_modules entry is a dev symlink into this
286
+ // checkout). An in-flight fold whose llm stream is cut by the dispose ends
287
+ // as a clean zero-chunk stream — inside the summarizer that is
288
+ // indistinguishable from a genuine empty model reply. Cycles record their
289
+ // start time; a dispose at/after that time reclassifies the outcome as
290
+ // reload-aborted so it neither climbs the failure ladder nor reads as a
291
+ // model failure in decisions.log.
292
+ let disposedAt = 0;
293
+ try {
294
+ // cordis emits no "dispose" event: self-teardown arrives as
295
+ // "internal/plugin" carrying OUR fiber with its uid CLEARED — uid is a
296
+ // positive counter at construction (vendor/cordis fiber.ts:239) and is
297
+ // set to null right before the disposal emit (:274-275; the DISPOSED
298
+ // state is defined as uid === null at :581). Same guard DSH ships in
299
+ // mcp-manager:249 and client-ui-cot-translation mount.ts:123.
300
+ ctx.on("internal/plugin", (fiber) => { if (fiber === ctx.fiber && fiber.uid === null) disposedAt = Date.now(); });
301
+ } catch {
302
+ /* lifecycle events unavailable on this ctx — flag stays 0, behavior unchanged */
303
+ }
304
+ const disposedSince = (since) => disposedAt !== 0 && disposedAt >= since;
305
+
257
306
  // Per-process pipeline state (process-wide by design: ledgers persist on
258
307
  // disk per session, metrics/captures live per session in memory).
259
308
  const store = createStore({ stateDir: readString(config, "trimStateDir", null) ?? undefined, warn });
@@ -276,21 +325,6 @@ export function applyContextTrim(ctx, config) {
276
325
  return false;
277
326
  }
278
327
  };
279
- // Durable surface-channel EVIDENCE for the banner: a block that already
280
- // SHIPPED a replacement (surfaceReplaced) proves the channel reaches the
281
- // wire, even across a process restart and after the in-memory recency
282
- // window expired. A commit-time capture (surfaceSeqs) does NOT — the block
283
- // may never ship, and a capture must not silence the honest wire=raw
284
- // warning. Display only — the fold gate uses the live capability probe.
285
- const ledgerShowsShippedReplacement = (sessionId) => {
286
- try {
287
- const blocks = store.ledgerOf(sessionId)?.blocks;
288
- if (!Array.isArray(blocks)) return false;
289
- return blocks.some((block) => block?.surfaceReplaced !== null && block?.surfaceReplaced !== undefined);
290
- } catch {
291
- return false;
292
- }
293
- };
294
328
  const metrics = createMetrics();
295
329
 
296
330
  /** Most recent provider prompt size for one session, or null when unknown.
@@ -444,12 +478,51 @@ export function applyContextTrim(ctx, config) {
444
478
  };
445
479
  const thresholdsNow = () => ({
446
480
  growthTokens: readNumber(config, "trimGrowthTokens", 22_500),
447
- incrementTokens: readNumber(config, "trimIncrementTokens", 50_000),
448
- pressurePercent: readNumber(config, "trimPressurePercent", 60),
481
+ incrementTokens: readNumber(config, "trimIncrementTokens", 25_000),
482
+ pressurePercent: readNumber(config, "trimPressurePercent", 65),
449
483
  emergencyPercent: readNumber(config, "trimEmergencyPercent", 95),
450
- protectedTail: Math.max(0, Math.round(readNumber(config, "trimProtectedTail", 6))),
451
- maxFoldsPerHour: readNumber(config, "trimMaxFoldsPerHour", 6),
484
+ protectedTail: Math.max(0, Math.round(readNumber(config, "trimProtectedTail", 10))),
485
+ maxFoldsPerHour: readNumber(config, "trimMaxFoldsPerHour", 30),
486
+ ladderT2Tokens: readNumber(config, "trimLadderT2Tokens", 8_000),
487
+ ladderT2MinBlocks: readNumber(config, "trimLadderT2MinBlocks", 3),
488
+ ladderT3Tokens: readNumber(config, "trimLadderT3Tokens", 6_000),
489
+ ladderT3MinBlocks: readNumber(config, "trimLadderT3MinBlocks", 2),
452
490
  });
491
+ const ladderEnabledNow = () => readBool(config, "trimLadderEnabled", true);
492
+ /** CJK-aware summary-mass estimator for the ladder census (same units
493
+ * the view estimate speaks, applied to summary text). */
494
+ const summaryTokensOf = (text) => messageTokens({ role: "user", content: [{ type: "text", text: typeof text === "string" ? text : "" }] });
495
+ /** Maximal runs of consecutive ACTIVE tier-1 / tier-2 blocks in ledger
496
+ * order — the ladder's candidate consumes sets (docs/ladder-fold.md).
497
+ * A run breaks on an inactive member, a tier change, or a tier-3 block
498
+ * interleaving. Runs carry { consumes, mass, count }; mass is the
499
+ * CJK-aware summary-mass estimate. Pure over the ledger. */
500
+ function ladderRunsOf(ledger) {
501
+ const runs = { t2: [], t3: [] };
502
+ if (ledger === null || !Array.isArray(ledger.blocks)) return runs;
503
+ let cur = null;
504
+ const flush = () => {
505
+ if (cur !== null && cur.ids.length > 0) {
506
+ const run = { consumes: cur.ids, mass: cur.mass, count: cur.ids.length };
507
+ if (cur.tier === 1) runs.t2.push(run);
508
+ else if (cur.tier === 2) runs.t3.push(run);
509
+ }
510
+ cur = null;
511
+ };
512
+ for (const block of ledger.blocks) {
513
+ const tier = block?.active === true && (block.tier === 1 || block.tier === 2) ? block.tier : null;
514
+ if (tier === null || (cur !== null && cur.tier !== tier)) {
515
+ flush();
516
+ }
517
+ if (tier !== null && cur === null) cur = { tier, ids: [], mass: 0 };
518
+ if (cur !== null) {
519
+ cur.ids.push(block.id);
520
+ cur.mass += summaryTokensOf(block.summary);
521
+ }
522
+ }
523
+ flush();
524
+ return runs;
525
+ }
453
526
  /** Effective-window cap: explicit trimWindowTokens, else auto-follow
454
527
  * wmForceTokens (keeps fold tiers ahead of the handover tier). */
455
528
  const windowCapNow = () => {
@@ -530,7 +603,11 @@ export function applyContextTrim(ctx, config) {
530
603
  // some dispatch options are frozen on top of that. injectView picks the
531
604
  // best strategy per dispatch and fails open; the view-applied line
532
605
  // records which strategy was used.
533
- const injected = injectView(options, messages, view);
606
+ // [2026-11-07]-[trimMode "off" means measure only]-[the view build runs
607
+ // for the meter, but the fold must not reach the wire — inspection
608
+ // report P0-2: off previously still spliced the folded view into the
609
+ // dispatch.]
610
+ const injected = modeNow() !== "off" ? injectView(options, messages, view) : "nothing-to-apply";
534
611
  const viewTokens = estimateTokensOf(view.messages);
535
612
  const blocksCount = Array.isArray(ledger.blocks) ? ledger.blocks.length : 0;
536
613
  // Wire truth in the row: the most recent provider prompt size (from the
@@ -558,8 +635,17 @@ export function applyContextTrim(ctx, config) {
558
635
  // history below the floor, so the guard must not stick: after
559
636
  // SIDE_DISPATCH_SKIP_BUDGET consecutive skips the dispatch is accepted
560
637
  // as main, which re-arms capture/baseline on the compacted history.
561
- const sideCandidate = inFlight.has(sessionId)
562
- || (view.applied === 0 && messages.length < SIDE_DISPATCH_MIN_MESSAGES && (blocksCount > 0 || captures.has(sessionId) || trimViewBySession.has(sessionId)));
638
+ // [2026-11-07]-[fold-span dispatches are self-noise, not side
639
+ // candidates]-[an in-flight fold cycle's own summarizer re-sends used
640
+ // to consume the side-dispatch skip budget: three consecutive span
641
+ // dispatches got "budget exhausted — accepting as main" and polluted
642
+ // the capture/baseline (the 2026-11-04 regression path). They now
643
+ // bypass unconditionally — never counted, never main.]
644
+ if (inFlight.has(sessionId)) {
645
+ decisionLog(`session=${sessionId} fold-span dispatch skipped view=${Math.round(viewTokens)} msgs=${messages.length}`);
646
+ return { sessionId, viewTokens, sideDispatch: true };
647
+ }
648
+ const sideCandidate = view.applied === 0 && messages.length < SIDE_DISPATCH_MIN_MESSAGES && (blocksCount > 0 || captures.has(sessionId) || trimViewBySession.has(sessionId));
563
649
  const skipCount = sideDispatchSkipsBySession.get(sessionId) ?? 0;
564
650
  if (sideCandidate && skipCount < SIDE_DISPATCH_SKIP_BUDGET) {
565
651
  sideDispatchSkipsBySession.set(sessionId, skipCount + 1);
@@ -613,11 +699,60 @@ export function applyContextTrim(ctx, config) {
613
699
  return null;
614
700
  }
615
701
 
702
+ /** Resolve the fold-fallback route (trimFoldFallbackModel): the second
703
+ * chance when the side/session route fails, and the last model stop
704
+ * before a raw pressure fold degrades to mechanical. Null = no fallback. */
705
+ function foldFallbackRouteFor() {
706
+ return readRoute(config, "trimFoldFallbackModel");
707
+ }
708
+
709
+ /** True when two resolved routes name the same provider+model (a fallback
710
+ * identical to the primary route would only double the failure latency). */
711
+ function sameRoute(a, b) {
712
+ return a !== null && b !== null && a.provider === b.provider && a.model === b.model;
713
+ }
714
+
616
715
  /** Run one fold cycle for a session (called at the idle boundary ONLY).
617
716
  * Returns a short outcome string for logging. */
618
- async function runFoldCycle(sessionId, decision) {
717
+ async function runFoldCycle(sessionId, decision, cycleStartedAt) {
619
718
  const capture = captures.get(sessionId);
620
719
  if (capture === undefined) return "no-capture";
720
+ const { protectedTail } = thresholdsNow();
721
+ // Ladder cycle (docs/ladder-fold.md): distill the decision's consumed
722
+ // run of block summaries into ONE replacement. Never mechanical — a
723
+ // failed distillation skips the cycle (fail-open + failure ladder).
724
+ if (decision.kind === "ladder") {
725
+ const ledgerBefore = store.ledgerOf(sessionId);
726
+ const byId = new Map(Array.isArray(ledgerBefore.blocks) ? ledgerBefore.blocks.map((block) => [block.id, block]) : []);
727
+ const consumed = decision.consumes.map((id) => byId.get(id)).filter((block) => block !== undefined);
728
+ if (consumed.length !== decision.consumes.length) return "ladder-consumed-missing";
729
+ const llm = ctx.get("llm", false);
730
+ const route = sideRouteFor(sessionId);
731
+ const fallback = foldFallbackRouteFor();
732
+ const approxTokens = consumed.reduce((sum, block) => sum + (Number.isFinite(block.approxTokens) && block.approxTokens > 0 ? block.approxTokens : 0), 0);
733
+ const summarize = (r, tag) => summarizeWithModel(llm, r, renderLadderMaterial(consumed), {
734
+ systemPrompt: LADDER_SYSTEM_PROMPT,
735
+ onFail: (why) => decisionLog(`session=${sessionId} summary-fail route=${tag} why=${why} blocks=${consumed.length} ~${approxTokens}t ladder=${decision.tier}`),
736
+ });
737
+ let summary = route !== null ? await summarize(route, "side") : null;
738
+ if (summary === null && fallback !== null && !sameRoute(fallback, route)) summary = await summarize(fallback, "fallback");
739
+ // Ladder folds never degrade to mechanical (docs/ladder-fold.md):
740
+ // after both routes fail the cycle stays fail-open with backoff.
741
+ if (summary === null) return disposedSince(cycleStartedAt) ? "reload-aborted" : "summary-unavailable"; // skip cycle, fail-open; a dispose cut is not a model failure
742
+ const result = store.commitFold(
743
+ sessionId,
744
+ { consumes: decision.consumes.slice(), summary, kind: "summary", tier: decision.tier === "ladder-3" ? 3 : 2, approxTokens },
745
+ null, // ladder commits validate against the ledger, never the capture
746
+ );
747
+ if (!result.ok) return `commit-rejected:${result.reason}`;
748
+ // Post-fold low baseline: the ladder shrinks the view; re-anchor
749
+ // the growth gate at the new low so it does not re-fire on stale
750
+ // anchor math (same pair semantics as raw folds).
751
+ const lowLedger = store.ledgerOf(sessionId);
752
+ const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail }).messages);
753
+ store.recordFoldBaseline(sessionId, capture.viewTokens, lowView);
754
+ return `folded:${decision.tier}:${decision.consumes.length} blocks (~${approxTokens} tokens)`;
755
+ }
621
756
  const { startIdx, endIdx } = decision;
622
757
  const slice = capture.messages.slice(startIdx, endIdx + 1);
623
758
  if (slice.length === 0) return "empty-slice";
@@ -626,6 +761,7 @@ export function applyContextTrim(ctx, config) {
626
761
  const approxTokens = estimateTokensOf(slice);
627
762
  let summary = null;
628
763
  let kind = "summary";
764
+ let degraded = false; // set when a PRESSURE fold falls back to mechanical
629
765
  if (decision.action === "emergency") {
630
766
  // Emergency cooldown timestamp set BEFORE the cycle: a commitFold
631
767
  // rejection (e.g. duplicate-range) must cool the emergency tier
@@ -639,10 +775,25 @@ export function applyContextTrim(ctx, config) {
639
775
  } else {
640
776
  const llm = ctx.get("llm", false);
641
777
  const route = sideRouteFor(sessionId);
642
- summary = await summarizeWithModel(llm, route, renderMaterial(capture.messages, startIdx, endIdx, refOf), {
643
- onFail: (why) => decisionLog(`session=${sessionId} summary-fail why=${why}`),
778
+ const fallback = foldFallbackRouteFor();
779
+ const material = renderMaterial(capture.messages, startIdx, endIdx, refOf);
780
+ const summarize = (r, tag) => summarizeWithModel(llm, r, material, {
781
+ onFail: (why) => decisionLog(`session=${sessionId} summary-fail route=${tag} why=${why} msgs=${count} ~${approxTokens}t`),
644
782
  });
645
- if (summary === null) return "summary-unavailable"; // skip cycle, fail-open
783
+ summary = route !== null ? await summarize(route, "side") : null;
784
+ if (summary === null && fallback !== null && !sameRoute(fallback, route)) summary = await summarize(fallback, "fallback");
785
+ // Last resort for PRESSURE folds only: relief beats fidelity when
786
+ // the window is nearly gone; growth folds stay fail-open (a
787
+ // mechanical growth fold would burn fold budget on junk). The
788
+ // outcome row marks the degrade so the log shows why no model
789
+ // summary landed.
790
+ if (summary === null && decision.tier === "pressure") {
791
+ if (disposedSince(cycleStartedAt)) return "reload-aborted";
792
+ summary = mechanicalSummary(capture.messages, startIdx, endIdx, refOf);
793
+ kind = "mechanical";
794
+ degraded = true;
795
+ }
796
+ if (summary === null) return disposedSince(cycleStartedAt) ? "reload-aborted" : "summary-unavailable"; // skip cycle, fail-open; a dispose cut is not a model failure
646
797
  }
647
798
  const positions = refPositions(capture.messages);
648
799
  const result = store.commitFold(
@@ -659,9 +810,9 @@ export function applyContextTrim(ctx, config) {
659
810
  // post-fold low (tier-a anchor — keeps the gate armed after folds
660
811
  // that commit at high view points).
661
812
  const lowLedger = store.ledgerOf(sessionId);
662
- const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail: thresholdsNow().protectedTail }).messages);
813
+ const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail }).messages);
663
814
  store.recordFoldBaseline(sessionId, capture.viewTokens, lowView);
664
- return `folded:${decision.tier}:${count} msgs (~${approxTokens} tokens)`;
815
+ return `folded:${decision.tier}:${count} msgs (~${approxTokens} tokens)${degraded ? " fallback=mechanical" : ""}`;
665
816
  }
666
817
 
667
818
  /** Idle-boundary hook (also re-used by the stream-path fallback): decide
@@ -692,18 +843,22 @@ export function applyContextTrim(ctx, config) {
692
843
  return;
693
844
  }
694
845
  inFlight.add(sessionId);
846
+ const cycleStartedAt = Date.now();
847
+ // Hoisted for the catch below: a dispose-cut throw may land before the
848
+ // trigger ever ran, and the catch must not touch a dead binding.
849
+ let decision = null;
695
850
  try {
696
851
  const { protectedTail, ...thresholds } = thresholdsNow();
697
- const foldable = computeFoldableRange(capture.messages, store.ledgerOf(sessionId), protectedTail);
698
- const windowTokens = await windowTokensFor(capture.model);
699
852
  const ledger = store.ledgerOf(sessionId);
853
+ const foldable = computeFoldableRange(capture.messages, ledger, protectedTail);
854
+ const windowTokens = await windowTokensFor(capture.model);
700
855
  // Pressure tiers run on the REAL prompt size when the tally is known:
701
856
  // max(view, wire). The view estimate misses the system/context
702
857
  // sections, so a 136k wire once read as a 50k view and the tiers
703
858
  // stayed silent. Unknown tally → exactly today's behavior.
704
859
  const wireTokens = wireTokensOf(sessionId);
705
860
  const pressureTokens = wireTokens !== null ? Math.max(capture.viewTokens, wireTokens) : capture.viewTokens;
706
- const decision = evaluateTrigger({
861
+ decision = evaluateTrigger({
707
862
  foldable,
708
863
  messages: capture.messages,
709
864
  viewTokens: pressureTokens,
@@ -714,23 +869,31 @@ export function applyContextTrim(ctx, config) {
714
869
  foldsThisHour: ledger.foldsThisHour,
715
870
  lastEmergencyAt: lastEmergencyAtBySession.get(sessionId) ?? 0,
716
871
  ledger,
872
+ ladderRuns: ladderEnabledNow() ? ladderRunsOf(ledger) : { t2: [], t3: [] },
717
873
  });
718
874
  decisionLog(`session=${sessionId} via=${via} action=${decision.action} tier=${decision.tier ?? "-"} reason=${decision.reason || "-"} view=${Math.round(capture.viewTokens)}${wireTokens === null ? "" : ` wire=${wireTokens}`} window=${windowTokens ?? "null"}`);
719
875
  if (decision.action === "none") return;
720
- const outcome = await runFoldCycle(sessionId, decision);
876
+ const outcome = await runFoldCycle(sessionId, decision, cycleStartedAt);
721
877
  // Failure ladder: commit resets it; a summarize/commit failure
722
878
  // climbs it (the no-backoff loop this closes — those outcomes
723
- // advance no cadence gate on their own).
879
+ // advance no cadence gate on their own). A ladder run whose
880
+ // consumed blocks vanished between trigger and commit is the same
881
+ // family: without backoff it would re-fire at every boundary.
724
882
  if (outcome.startsWith("folded:")) clearFoldFailure(sessionId);
725
- else if (outcome === "summary-unavailable" || outcome.startsWith("commit-rejected:")) recordFoldFailure(sessionId);
883
+ else if (outcome === "summary-unavailable" || outcome.startsWith("commit-rejected:") || outcome === "ladder-consumed-missing") recordFoldFailure(sessionId);
726
884
  // Outcome lands in the decision log too: warn() has no reachable
727
885
  // sink on some hosts, and the commit/fail boundary is exactly what
728
886
  // live diagnosis needs.
729
- decisionLog(`session=${sessionId} outcome=${outcome} tier=${decision.tier}`);
887
+ decisionLog(`session=${sessionId} outcome=${outcome} tier=${decision.tier ?? "-"}`);
730
888
  warn(`[context-trim] ${sessionId}: ${outcome} (${decision.reason})`);
731
889
  } catch (error) {
732
- recordFoldFailure(sessionId); // a thrown cycle counts as failed too
733
- warn(`[context-trim] fold cycle failed: ${error?.message ?? error}`);
890
+ // A dispose mid-cycle throws through here too (ctx/service teardown) —
891
+ // that is a reload cut, not a model failure: log it and skip the ladder.
892
+ if (disposedSince(cycleStartedAt)) decisionLog(`session=${sessionId} outcome=reload-aborted tier=${decision?.tier ?? "-"}`);
893
+ else {
894
+ recordFoldFailure(sessionId); // a thrown cycle counts as failed too
895
+ warn(`[context-trim] fold cycle failed: ${error?.message ?? error}`);
896
+ }
734
897
  } finally {
735
898
  inFlight.delete(sessionId);
736
899
  }
@@ -738,63 +901,17 @@ export function applyContextTrim(ctx, config) {
738
901
 
739
902
  // ---- host registrations (each individually fail-open) --------------------
740
903
 
904
+ // MODEL-VISIBILITY INVARIANT: only the byte-stable protocol head is
905
+ // registered. The previous live context line (view tokens, % of window,
906
+ // folds, growth) is gone by design — water levels and progress numbers
907
+ // steer model judgment (bili lesson #2, docs/ladder-fold.md). Human
908
+ // diagnostics live in decisions.log (view-applied / decision / outcome
909
+ // rows) and the client badge.
741
910
  try {
742
- ctx.systemPrompt?.section?.({ name: "switchman:trim", order: 10450, interpolate: false, text: () => TRIM_PROTOCOL_TEXT });
911
+ ctx.systemPrompt?.section?.({ name: "switchman:trim", order: 10450, interpolate: false, text: () => (enabledNow() && modeNow() !== "off" ? TRIM_PROTOCOL_TEXT : "") });
743
912
  } catch (error) {
744
913
  warn(`[context-trim] systemPrompt.section failed: ${error?.message ?? error}`);
745
914
  }
746
- try {
747
- ctx.systemPrompt?.context?.({
748
- name: "switchman:trim",
749
- order: 299,
750
- required: true,
751
- interpolate: false,
752
- text: (context) => {
753
- try {
754
- const agent = context?.agent ?? null;
755
- // context.agent is also the agent OBJECT — derive the id plus a
756
- // display label so the banner never prints [object Object].
757
- const sessionId = agentSessionIdOf(agent);
758
- const label = agentLabelOf(agent);
759
- const record = sessionId !== null ? metrics.of(sessionId) : null;
760
- const ledger = sessionId !== null ? store.ledgerOf(sessionId) : null;
761
- const model = captureModelOf(sessionId);
762
- const windowTokens = model === null ? null : cachedWindowOf(model);
763
- const growthBaseline = ledger !== null && Number.isFinite(ledger.lastFoldLowTokens) && ledger.lastFoldLowTokens > 0 ? ledger.lastFoldLowTokens : ledger !== null && Number.isFinite(ledger.lastFoldViewTokens) && ledger.lastFoldViewTokens > 0 ? ledger.lastFoldViewTokens : null;
764
- // Honest wire status: the wire is only "raw" when NEITHER channel
765
- // can reach it — a capable surface pass, or a ledger that already
766
- // proves a SHIPPED replacement (a capture alone does not: it may
767
- // never ship), keeps the marker off even after the in-memory
768
- // recency window expired.
769
- const wireRaw = recentTrimInjectOf(sessionId) === "none" && !surfaceChannelCapable(sessionId) && !ledgerShowsShippedReplacement(sessionId);
770
- return renderTrimContextLine(label, {
771
- enabled: enabledNow(),
772
- mode: modeNow(),
773
- viewTokens: record?.viewTokens ?? null,
774
- windowTokens,
775
- folds: Array.isArray(ledger?.blocks) ? ledger.blocks.length : 0,
776
- growthTokens: record?.viewTokens !== null && record !== undefined && growthBaseline !== null ? record.viewTokens - growthBaseline : null,
777
- lastModel: record?.lastModel ?? null,
778
- wireRaw,
779
- });
780
- } catch {
781
- return "";
782
- }
783
- },
784
- });
785
- } catch (error) {
786
- warn(`[context-trim] systemPrompt.context failed: ${error?.message ?? error}`);
787
- }
788
-
789
- /** Synchronous window lookup for the banner: raw cache composed with
790
- * the cap (async resolution happens in the idle path / stream path). */
791
- function captureModelOf(sessionId) {
792
- return captures.get(sessionId)?.model ?? null;
793
- }
794
- function cachedWindowOf(model) {
795
- const resolved = model !== null && typeof model.provider === "string" && typeof model.model === "string" ? windowCache.get(`${model.provider}/${model.model}`)?.windowTokens ?? null : null;
796
- return effectiveWindowTokens(resolved, windowCapNow());
797
- }
798
915
 
799
916
  try {
800
917
  ctx.on("agent/status", (payload) => {
@@ -860,29 +977,56 @@ export function applyContextTrim(ctx, config) {
860
977
 
861
978
  // ---- tools (L1 advisory surface) ------------------------------------------
862
979
 
980
+ /** Render the fold ledger for the status tool: ACTIVE blocks with true
981
+ * sizes, absorbed history as one line. NO water level, NO fill
982
+ * percentage, NO window math, NO threshold percentages — sizes of
983
+ * concrete ranges are the model-facing interface (bili mechanism #5);
984
+ * global progress numbers are not (lesson #2). */
985
+ function renderStatusLedger(ledger) {
986
+ const blocks = Array.isArray(ledger?.blocks) ? ledger.blocks : [];
987
+ const active = blocks.filter((block) => block?.active === true);
988
+ const absorbed = blocks.length - active.length;
989
+ const lines = [];
990
+ if (blocks.length === 0) {
991
+ lines.push("fold ledger: empty (nothing folded yet)");
992
+ return lines;
993
+ }
994
+ lines.push(`fold ledger: ${active.length} active block(s)${absorbed > 0 ? `, ${absorbed} absorbed by higher tiers` : ""} (oldest first)`);
995
+ for (const block of active) {
996
+ const tier = block.tier === 3 ? "T3" : block.tier === 2 ? "T2" : "T1";
997
+ const range = block.refRange !== null && block.refRange !== undefined ? ` refs [m${block.refRange.firstId}]..[m${block.refRange.lastId}]` : ` distills ${block.count} lower-tier block(s)`;
998
+ lines.push(`- ${block.id} ${tier}: ~${Math.round(Number.isFinite(block.approxTokens) ? block.approxTokens : 0)} tokens of material${range}${block.tier >= 2 ? ` — foldable into a higher tier together with its sibling blocks` : ""}`);
999
+ }
1000
+ return lines;
1001
+ }
1002
+
863
1003
  try {
864
1004
  ctx.tools?.register?.({
865
1005
  name: "ctx_trim_status",
866
- description: "Inspect the context-trim state of the current session: mode, thresholds, view token estimate, fold ledger, per-message refs of the captured history. Read-only.",
1006
+ description: "List this session's compressible context ranges with their true sizes: the fold ledger, the raw range a fold would cover, and distillation targets. Read-only. Contains sizes only — never progress percentages.",
867
1007
  parameters: { type: "object", properties: {}, additionalProperties: false },
868
1008
  required: [],
869
1009
  async execute() {
870
1010
  const sessionId = sessionIdOf({});
871
1011
  if (sessionId === null) return "No session context available.";
1012
+ if (!enabledNow()) return "Context trimming is disabled for this deployment.";
872
1013
  const ledger = store.ledgerOf(sessionId);
873
- const record = metrics.of(sessionId);
874
1014
  const capture = captures.get(sessionId);
875
- const { protectedTail, ...thresholds } = thresholdsNow();
876
- const lines = [
877
- "ctx_trim_status",
878
- `mode: ${modeNow()} (enabled: ${enabledNow()})`,
879
- `view tokens (estimate): ${record?.viewTokens ?? "n/a"}`,
880
- `window tokens: ${cachedWindowOf(capture?.model ?? null) ?? "unknown"}`,
881
- `folds: ${Array.isArray(ledger.blocks) ? ledger.blocks.length : 0}, frontier: ${ledger.frontier ?? "none"}, last fold view tokens: ${ledger.lastFoldViewTokens || "n/a"}`,
882
- `thresholds: growth>${thresholds.growthTokens} increment>=${thresholds.incrementTokens} pressure=${thresholds.pressurePercent}% emergency=${thresholds.emergencyPercent}% maxPerHour=${thresholds.maxFoldsPerHour} protectedTail=${protectedTail}`,
883
- ];
1015
+ const { protectedTail } = thresholdsNow();
1016
+ const lines = [`ctx_trim_status — mode: ${modeNow()} (enabled: ${enabledNow()})`];
1017
+ lines.push(...renderStatusLedger(ledger));
1018
+ // The raw range a fold would cover next, with its true size.
884
1019
  if (capture !== undefined) {
885
- lines.push("", `captured history (${capture.messages.length} messages; refs [m...] are fold anchors):`);
1020
+ const foldable = computeFoldableRange(capture.messages, ledger, protectedTail);
1021
+ if (foldable !== null) {
1022
+ const firstRef = refOf(capture.messages[foldable.startIdx], foldable.startIdx);
1023
+ const lastRef = refOf(capture.messages[foldable.endIdx], foldable.endIdx);
1024
+ lines.push("", `compressible raw range: [m${firstRef}]..[m${lastRef}] (${foldable.endIdx - foldable.startIdx + 1} messages, ~${estimateTokensOf(capture.messages, foldable.startIdx, foldable.endIdx)} tokens) — a ctx_trim_fold over this range replaces it with your summary.`);
1025
+ } else {
1026
+ lines.push("", "compressible raw range: none right now (everything settled is already folded).");
1027
+ }
1028
+ // Message refs stay available for fold targeting (sizes only).
1029
+ lines.push("", `captured history (${capture.messages.length} messages; refs [m...] are fold anchors, per-message sizes are estimates):`);
886
1030
  capture.messages.forEach((message, i) => {
887
1031
  lines.push(`- [m${refOf(message, i)}] (${message?.role ?? "?"}) ~${estimateTokensOf(capture.messages, i, i)} tokens`);
888
1032
  });
@@ -914,6 +1058,7 @@ export function applyContextTrim(ctx, config) {
914
1058
  async execute(args) {
915
1059
  const sessionId = sessionIdOf({});
916
1060
  if (sessionId === null) return "No session context available.";
1061
+ if (!enabledNow() || modeNow() === "off") return "Context trimming is disabled for this deployment.";
917
1062
  const capture = captures.get(sessionId);
918
1063
  if (capture === undefined) return "No captured history yet — fold is unavailable until the next model call.";
919
1064
  const positions = refPositions(capture.messages);
@@ -945,7 +1090,7 @@ export function applyContextTrim(ctx, config) {
945
1090
  approxTokens: estimateTokensOf(capture.messages, firstIdx, lastIdx),
946
1091
  summary: String(args.summary ?? ""),
947
1092
  kind: "summary",
948
- tier: "model",
1093
+ tier: 1,
949
1094
  },
950
1095
  positions,
951
1096
  );
@@ -968,10 +1113,14 @@ export function applyContextTrim(ctx, config) {
968
1113
 
969
1114
  function streamListener(options, next) {
970
1115
  let viewTokens = null;
1116
+ let mainDispatch = false;
971
1117
  try {
972
1118
  if (enabledNow()) {
973
1119
  const applied = applyViewToOptions(options);
974
- if (applied !== null) viewTokens = applied.viewTokens;
1120
+ if (applied !== null) {
1121
+ viewTokens = applied.viewTokens;
1122
+ mainDispatch = applied.sideDispatch !== true;
1123
+ }
975
1124
  }
976
1125
  } catch (error) {
977
1126
  decisionLog(`view-build-failed why=${error?.message ?? error}`);
@@ -985,7 +1134,10 @@ export function applyContextTrim(ctx, config) {
985
1134
  if (wrappedSession === null) return result;
986
1135
  return wrapStreamForMetrics(result, (chunk) => {
987
1136
  const usage = extractUsage(chunk);
988
- if (usage !== null) metrics.recordUsage(wrappedSession, usage);
1137
+ if (usage !== null) {
1138
+ metrics.recordUsage(wrappedSession, usage);
1139
+ if (mainDispatch) noteTrimWireTokens(wrappedSession, usage);
1140
+ }
989
1141
  });
990
1142
  } catch (error) {
991
1143
  warn(`[context-trim] stream wrap failed: ${error?.message ?? error}`);
@@ -994,14 +1146,19 @@ export function applyContextTrim(ctx, config) {
994
1146
  // Fallback fold check on the stream path: the agent/status idle event
995
1147
  // may not reach a globally-registered listener on every host topology,
996
1148
  // so each stream end also runs a throttled (60 s/session) check. The
997
- // trigger's own cadence gates still guard over-folding.
1149
+ // trigger's own cadence gates still guard over-folding. After the fold
1150
+ // check, chain a surface sweep: fold blocks commit mid-run even for
1151
+ // sessions whose dispatches are host-frozen (subagent children see
1152
+ // exactly ONE idle — at task end — so without this their wire ships
1153
+ // raw history until they finish; the sweep is the only relief channel
1154
+ // that reaches frozen dispatches). Same 60 s cadence, fail-open.
998
1155
  try {
999
1156
  if (enabledNow() && modeNow() === "auto" && wrappedSession !== null) {
1000
1157
  const nowMs = Date.now();
1001
1158
  if (nowMs - (lastStreamCheckAt.get(wrappedSession) ?? 0) >= 60_000) {
1002
1159
  lastStreamCheckAt.set(wrappedSession, nowMs);
1003
1160
  if (lastStreamCheckAt.size > 200) lastStreamCheckAt.delete(lastStreamCheckAt.keys().next().value);
1004
- void maybeFold(wrappedSession, "stream");
1161
+ void maybeFold(wrappedSession, "stream").finally(() => surfacePass.sweep(wrappedSession, "stream"));
1005
1162
  }
1006
1163
  }
1007
1164
  } catch {
@@ -1050,5 +1207,5 @@ export function applyContextTrim(ctx, config) {
1050
1207
  warn(`[context-trim] ctx.inject(llm) failed: ${error?.message ?? error}`);
1051
1208
  }
1052
1209
 
1053
- return { store, metrics, captures, inFlight, maybeFold, maybeInherit, applyViewToOptions, surfacePass, renderTrimContextLine: (agent) => renderTrimContextLine(agent, { enabled: enabledNow(), mode: modeNow(), viewTokens: null, windowTokens: null, folds: 0, growthTokens: null, lastModel: null, wireRaw: false }) };
1210
+ return { store, metrics, captures, inFlight, maybeFold, maybeInherit, applyViewToOptions, surfacePass };
1054
1211
  }