dsh-switchman 1.11.5 → 1.11.7

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.
@@ -24,15 +24,16 @@
24
24
  *
25
25
  * Config fields (host/config.js, all volatile): trimEnabled, trimMode
26
26
  * ("off"|"advisory"|"auto"), trimGrowthTokens, trimIncrementTokens,
27
- * trimPressurePercent, trimEmergencyPercent, trimWindowTokens,
27
+ * trimPressurePercent, trimEmergencyPercent, trimAdvisoryPercent,
28
+ * trimAdvisoryCriticalPercent, trimWindowTokens,
28
29
  * trimProtectedTail, trimMaxFoldsPerHour, trimSideModel, trimFoldFallbackModel, trimStateDir,
29
30
  * trimLadderEnabled, trimLadderT2Tokens, trimLadderT2MinBlocks,
30
31
  * trimLadderT3Tokens, trimLadderT3MinBlocks.
31
32
  */
32
33
 
33
- import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
34
- import { join } from "node:path";
35
- import { buildView, estimateTokensOf, injectView, ledgerIsStale, messageTokens, rangeIsSegmentAligned, refOf, refPositions } from "./view.js";
34
+ import { appendFileSync, mkdirSync, realpathSync, renameSync, statSync } from "node:fs";
35
+ import { join, resolve } from "node:path";
36
+ import { buildView, estimateTokensOf, injectView, isFoldSummaryMessage, ledgerIsStale, messageTokens, rangeIsSegmentAligned, refOf, refPositions } from "./view.js";
36
37
  import { computeFoldableRange, evaluateTrigger, EMERGENCY_COOLDOWN_MS } from "./trigger.js";
37
38
  import { LADDER_SYSTEM_PROMPT, mechanicalSummary, renderLadderMaterial, renderMaterial, summarizeWithModel } from "./summarizer.js";
38
39
  import { createStore } from "./state.js";
@@ -40,6 +41,46 @@ import { createMetrics, extractUsage, promptTokensOf, wrapStreamForMetrics } fro
40
41
  import { forkLinkOf, loadForkLinks, recordForkLink, resolveInheritance, INHERIT_WINDOW_MS } from "./fork.js";
41
42
  import { createSurfaceFoldPass } from "./surface.js";
42
43
  import { childSessionIdOf } from "../subagent-model.js";
44
+ import { tmpdir } from "node:os";
45
+ import { dshHome } from "../lib/dsh-home.js";
46
+ import { fileURLToPath } from "node:url";
47
+
48
+ // Module build stamp ([2026-11-15]-[stale-module observability]): the
49
+ // per-dispatch view-applied row carries it, so a host process still
50
+ // running a pre-reload copy of this engine is identifiable in
51
+ // decisions.log without after-the-fact reconciliation. Fail-open: 0
52
+ // omits the field (2026-11-15 incident: a stale module nudged at
53
+ // 81-125% for two hours while the relief code sat uninstalled on disk —
54
+ // nothing in the log distinguished the running copy from the repo one).
55
+ const MODULE_BUILD_STAMP = (() => {
56
+ try {
57
+ return Math.round(statSync(fileURLToPath(import.meta.url)).mtimeMs);
58
+ } catch {
59
+ return 0;
60
+ }
61
+ })();
62
+ /** Resolve the configured ledger state dir to an absolute path that
63
+ * stays inside the plugin's own state root (<dshHome>/.dsh-switchman/
64
+ * state/trim/) or the OS temp root (os.tmpdir(), for throwaway test
65
+ * state). The dir is settings-writable, and the ledger writer mkdirSyncs
66
+ * and writes JSON there — an arbitrary out-of-root value would aim that
67
+ * writer at any directory. Empty/unset keeps the store default; a
68
+ * relative value resolves inside the root; anything else is refused
69
+ * with a warn and the default root is used. Exported for tests. */
70
+ export function resolveStateDir(raw, warn = () => {}) {
71
+ const root = resolve(dshHome(), ".dsh-switchman", "state", "trim");
72
+ if (typeof raw !== "string" || raw.trim() === "") return undefined;
73
+ const candidate = resolve(root, raw);
74
+ if (candidate === root || candidate.startsWith(root + "/")) return candidate;
75
+ // The OS temp root hosts throwaway state (tests mkdtemp under it); it is
76
+ // per-user and 0700 on macOS/Linux, so it stays inside the threat model.
77
+ const tmpRoots = [resolve(tmpdir())];
78
+ try { tmpRoots.push(realpathSync(tmpRoots[0])); } catch { /* first is enough */ }
79
+ if (tmpRoots.some((t) => candidate === t || candidate.startsWith(t + "/"))) return candidate;
80
+ warn(`dsh-switchman: trimStateDir refused (must stay under ${root} or the OS temp root): ${raw}`);
81
+ return undefined;
82
+ }
83
+
43
84
 
44
85
  // ---- agent reference helpers --------------------------------------------------
45
86
 
@@ -58,6 +99,7 @@ import { childSessionIdOf } from "../subagent-model.js";
58
99
  // it and the stored tally takes over.
59
100
  const trimViewBySession = new Map(); // sessionId -> { viewTokens, at, inject, lastNoneAt }
60
101
  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)
102
+ const trimProjectedBySession = new Map(); // sessionId -> { totalTokens, at } — last host-meter reading (tokenMeter.measure().totalTokens: provider-anchored projection of the NEXT prompt, the figure the DSH context panel displays). Observability only — recorded at the idle boundary where the agent object is reachable; the trim tiers never read it.
61
103
  const capturedModelBySession = new Map(); // sessionId -> { provider, model, at } — last stream-captured model route, read by the watermark layer to scale subagent tiers to the session's own model window
62
104
  /** Freshness TTL for the in-memory view/injection registry (watermark and
63
105
  * banner display only). NEVER a fold gate: gating folds on "did the surface
@@ -177,6 +219,45 @@ function noteTrimWireTokens(sessionId, usage) {
177
219
  }
178
220
  }
179
221
 
222
+ /** Record the host meter's projected prompt size for one session. Read from
223
+ * the agent/status idle boundary (the only place the agent object — and thus
224
+ * agent.session for tokenMeter.measure — is reachable in this layer). The
225
+ * figure is the DSH context panel's number (projectedTokens domain):
226
+ * provider-anchored plus heuristic surface deltas, i.e. what the NEXT prompt
227
+ * would cost — ahead of the wire, which only updates when the next response
228
+ * reports usage. Observability only: it lands in the view-applied row as
229
+ * proj=<n> so view/wire/proj divergence (estimator vs provider vs
230
+ * projection-domain) is one grep away. Fail-open, bounded (200 sessions). */
231
+ function noteTrimProjectedTokens(sessionId, agent, ctx) {
232
+ try {
233
+ if (typeof sessionId !== "string" || sessionId === "") return;
234
+ const measurement = agent?.session ? ctx?.tokenMeter?.measure?.(agent.session) : undefined;
235
+ const total = Number.isFinite(measurement?.totalTokens) ? measurement.totalTokens : null;
236
+ if (total === null) return;
237
+ trimProjectedBySession.set(sessionId, { totalTokens: total, at: Date.now() });
238
+ if (trimProjectedBySession.size > 200) trimProjectedBySession.delete(trimProjectedBySession.keys().next().value);
239
+ } catch {
240
+ /* observability must never throw */
241
+ }
242
+ }
243
+
244
+ // ---- pressure-snapshot bridge (client badge) -------------------------------
245
+ // The routes layer (host/routes.js) serves GET /trim-pressure for the client
246
+ // dock chip; the live window/pressure math lives in THIS closure. A module
247
+ // bridge mirrors the recentTrim*Of pattern: applyContextTrim publishes a
248
+ // snapshot function once at mount; the route reads it per request. Null
249
+ // publisher (module unloaded) or null window (nothing to measure against)
250
+ // degrades to { available: false } — the chip renders nothing.
251
+ let trimPressureSnapshotFn = null;
252
+ export function trimPressureSnapshotOf(sessionId) {
253
+ try {
254
+ if (typeof trimPressureSnapshotFn !== "function") return { available: false };
255
+ return trimPressureSnapshotFn(sessionId) ?? { available: false };
256
+ } catch {
257
+ return { available: false };
258
+ }
259
+ }
260
+
180
261
  /** Injection outcome of the last dispatch for one session ("in-place" |
181
262
  * "swapped" | "none"), or null when unknown or stale. Read by maybeFold:
182
263
  * when the wire cannot carry the view, folding cannot relieve anything and
@@ -271,9 +352,81 @@ export const TRIM_PROTOCOL_TEXT = [
271
352
  "- 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.",
272
353
  "- NEVER contradict or re-derive content a summary already settled; build on it.",
273
354
  "- 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.",
355
+ "- When run_code is your only directly callable tool (program-transport mode), invoke these from inside a run_code program: await tools.ctx_trim_status({}) to list ranges, await tools.ctx_trim_fold({ firstId, lastId, count, summary }) to fold.",
274
356
  "- Tool-call pairs (an assistant tool call and its results) must always be folded together or not at all.",
275
357
  ].join("\n");
276
358
 
359
+ // Advisory nudge thresholds: the default speak line (50%) deliberately sits
360
+ // well below the protocol's own 80% fold line so the model is nudged while
361
+ // it still has comfortable headroom; critical wording escalates at 80%.
362
+ // [2026-11-15]-[advisory lines recalibrated 65/95 → 50/80]-[the 2026-11-14
363
+ // turn-25 incident (nudges ignored, 74%→99% in 28 minutes) showed a speak
364
+ // line at 65% arrives too late to matter]. At most one nudge per session
365
+ // per 10 minutes (an instant re-nudge when the level band changes). Both
366
+ // lines are config-tunable via trimAdvisoryPercent / trimAdvisoryCriticalPercent.
367
+ export const ADVISORY_NUDGE_PERCENT = 50;
368
+ export const ADVISORY_NUDGE_CRITICAL_PERCENT = 80;
369
+ /** Repeat cadence of the live tail nudge, per band. The nudge REPLACES
370
+ * itself at the new surface tail (prune old seq, append fresh) — the old
371
+ * 10-minute single repeat let a spoken nudge slide under ~50 messages of
372
+ * new turn output and become background noise (2026-11-15 turn observation:
373
+ * the model folded only after the throttle window expired). Critical
374
+ * escalates faster than high; relief (>= 80%) owns the mechanical path
375
+ * independently of this cadence. Config-tunable via trimAdvisoryRepeatMs,
376
+ * falling back to the defaults when absent or out of range. */
377
+ export const ADVISORY_NUDGE_REPEAT_MS = 120_000;
378
+ export const ADVISORY_NUDGE_REPEAT_CRITICAL_MS = 45_000;
379
+
380
+ /** Effective advisory lines for one dispatch: config-tunable, falling back to
381
+ * the configured defaults (50/80) when absent, inverted, or out of range —
382
+ * a broken pair never silences the nudge. */
383
+ function advisoryNudgeLines(config) {
384
+ const line = readNumber(config, "trimAdvisoryPercent", ADVISORY_NUDGE_PERCENT);
385
+ const critical = readNumber(config, "trimAdvisoryCriticalPercent", ADVISORY_NUDGE_CRITICAL_PERCENT);
386
+ const sane = (v) => Number.isFinite(v) && v > 0 && v <= 100;
387
+ if (!sane(line) || !sane(critical) || line >= critical) return { line: ADVISORY_NUDGE_PERCENT, critical: ADVISORY_NUDGE_CRITICAL_PERCENT };
388
+ return { line, critical };
389
+ }
390
+
391
+ /** Effective nudge repeat cadence for one dispatch: config-tunable
392
+ * (trimAdvisoryRepeatMs), falling back to the built-in per-band defaults
393
+ * when absent, negative, or above one hour — a broken value never pins
394
+ * the nudge to the old 10-minute cadence nor spams every dispatch. */
395
+ function advisoryRepeatLines(config) {
396
+ const highMs = readNumber(config, "trimAdvisoryRepeatMs", ADVISORY_NUDGE_REPEAT_MS);
397
+ const saneMs = (v) => Number.isFinite(v) && v >= 5_000 && v <= 3_600_000;
398
+ if (!saneMs(highMs)) return { highMs: ADVISORY_NUDGE_REPEAT_MS, criticalMs: ADVISORY_NUDGE_REPEAT_CRITICAL_MS };
399
+ return { highMs, criticalMs: Math.min(highMs, ADVISORY_NUDGE_REPEAT_CRITICAL_MS) };
400
+ }
401
+
402
+ /** Advisory-mode nudge text. Deliberate, bounded exception to the
403
+ * digit-free disclosure policy of TRIM_PROTOCOL_TEXT above: advisory mode
404
+ * never folds on a schedule (the modeNow gate); besides the model's own
405
+ * fold, the only engine-initiated relief is the one-shot critical relief
406
+ * fold (trimCriticalRelief, cooldown-gated). At threshold, the live-number
407
+ * instruction is appended to the session surface tail, not injected into
408
+ * the registered system section. */
409
+ export function advisoryNudgeText(pressurePercent, critical, foldLine = ADVISORY_NUDGE_PERCENT, criticalLine = ADVISORY_NUDGE_CRITICAL_PERCENT, target = null, nth = 0) {
410
+ const action = critical
411
+ ? `Pressure is CRITICAL (${pressurePercent}% ≥ ${criticalLine}%): fold down to under ~50% of the context IN THIS TURN.`
412
+ : `At ${pressurePercent}% (fold line ${foldLine}%): ${foldLine}–${foldLine + 9}% → fold roughly half of the older history now; ≥${foldLine + 10}% → fold down to under ~50%.`;
413
+ if (nth >= 2) {
414
+ const targetLine = target !== null ? ` Recommended: ${target}` : " Get ranges with ctx_trim_status."; // eslint-disable-line no-template-curly-in-string
415
+ return [
416
+ `[SWITCHMAN:TRIM] Advisory nudge (repeat #${nth + 1}) — still above the fold line. ${action} Fold FIRST, resume afterwards.${targetLine}`
417
+ ].join("\n");
418
+ }
419
+ const actNow = target !== null
420
+ ? `Act now: ONE ctx_trim_fold call is enough — fold the recommended range. Recommended: ${target}. (tool-call pairs fold together or not at all; existing summaries stay.) If run_code is your only directly callable tool, run: await tools.ctx_trim_fold({ firstId: "<firstRef>", lastId: "<lastRef>", count: <count>, summary: <your summary> }). Do not wait for a routine moment.`
421
+ : "Act now: call ctx_trim_status to list compressible ranges, pick one contiguous range, and fold it with ctx_trim_fold (tool-call pairs fold together or not at all; existing summaries stay). If run_code is your only directly callable tool, run both inside a program: await tools.ctx_trim_status({}) then await tools.ctx_trim_fold({ firstId, lastId, count, summary }). Do not wait for a routine moment.";
422
+ return [
423
+ '[SWITCHMAN:TRIM] Advisory nudge — pinned to the session tail until pressure falls below the fold line.',
424
+ `Live context pressure on this session has reached the fold line. ${action}`,
425
+ "FOLD FIRST: treat this nudge as a preemption, not background advice. Finish the current tool call if (and only if) aborting it mid-flight would leave broken state; otherwise interrupt the task queue and fold before continuing. Every queued step, plan item and pending subtask waits until pressure is back under the fold line. The only valid excuse to defer is a task that genuinely cannot be resumed after an interruption — and even then, fold at the first safe pause rather than at task end.",
426
+ actNow
427
+ ].join("\n");
428
+ }
429
+
277
430
  /** Effective window for the pressure/emergency tier math:
278
431
  * min(model-resolved window, configured cap). Null only when BOTH inputs
279
432
  * are unusable — the trigger then skips those tiers (fail-open). */
@@ -323,7 +476,7 @@ export function applyContextTrim(ctx, config) {
323
476
 
324
477
  // Per-process pipeline state (process-wide by design: ledgers persist on
325
478
  // disk per session, metrics/captures live per session in memory).
326
- const store = createStore({ stateDir: readString(config, "trimStateDir", null) ?? undefined, warn });
479
+ const store = createStore({ stateDir: resolveStateDir(readString(config, "trimStateDir", null), warn) ?? undefined, warn });
327
480
 
328
481
  // Surface-replace pass (docs/surface-fold.md): ships committed fold blocks
329
482
  // onto the session surface so folds reach the wire even for frozen
@@ -359,10 +512,319 @@ export function applyContextTrim(ctx, config) {
359
512
  return null;
360
513
  }
361
514
  };
515
+
516
+ // Advisory nudge state: sessionId -> { at, critical, seq }. Bounded like the
517
+ // other per-session registries (200 entries).
518
+ const advisoryNudgeBySession = new Map();
519
+
520
+ // Single-flight for nudge warm-ups: during resolveRawWindow's <=10s window
521
+ // every main dispatch would otherwise re-fire the same resolve. Entries
522
+ // self-delete on settle; the expiry is only a belt-and-suspenders bound.
523
+ const windowWarmupInFlight = new Map(); // "provider/model" -> expiresAt
524
+ function warmWindowOnce(model, now) {
525
+ const key = `${model.provider}/${model.model}`;
526
+ const until = windowWarmupInFlight.get(key);
527
+ if (until !== undefined && now < until) return;
528
+ windowWarmupInFlight.set(key, now + 15_000); // > resolveRawWindow's 10s ceiling
529
+ void windowTokensFor(model).catch(() => {}).finally(() => {
530
+ if (windowWarmupInFlight.get(key) === until) windowWarmupInFlight.delete(key);
531
+ });
532
+ }
533
+
534
+ /** One-step fold target for the nudge text: the SAME recommendation the
535
+ * ctx_trim_status tool prints (first/last message refs + count of the raw
536
+ * compressible range). null when no capture exists yet or nothing is
537
+ * foldable — the nudge then keeps the two-step status-first wording.
538
+ * Read-only over the live capture: no host access, no throw. */
539
+ function recommendedFoldTargetOf(sessionId) {
540
+ try {
541
+ const capture = captures.get(sessionId);
542
+ if (capture === undefined || !Array.isArray(capture.messages) || capture.messages.length === 0) return null;
543
+ const ledger = store.ledgerOf(sessionId);
544
+ const { protectedTail, protectTaskIntent } = thresholdsNow();
545
+ const foldable = computeFoldableRange(capture.messages, ledger, protectedTail, { protectTaskIntent });
546
+ if (foldable === null) return null;
547
+ const firstRef = refOf(capture.messages[foldable.startIdx], foldable.startIdx);
548
+ const lastRef = refOf(capture.messages[foldable.endIdx], foldable.endIdx);
549
+ return {
550
+ firstId: "m" + firstRef,
551
+ lastId: "m" + lastRef,
552
+ count: foldable.endIdx - foldable.startIdx + 1,
553
+ tokens: estimateTokensOf(capture.messages, foldable.startIdx, foldable.endIdx),
554
+ };
555
+ } catch {
556
+ return null;
557
+ }
558
+ }
559
+
560
+ /** Advisory-mode pressure nudge (text: advisoryNudgeText). Appends the live
561
+ * instruction to the session surface tail once pressure crosses the fold
562
+ * line. Pressure reuses the meter-duality bridge (wire preferred, view
563
+ * estimate as floor) against the session model's window read from the SYNC
564
+ * cache — a cache miss fires an async warm-up (windowTokensFor) and defers
565
+ * the nudge to a later dispatch instead of blocking this one — and it only
566
+ * reaches the wire on the NEXT dispatch (the current request is already
567
+ * assembled). The speak is a pure suffix, so the prompt prefix stays
568
+ * untouched; a later clear or upgrade prunes only the nudge nodes, so
569
+ * cache reuse survives up to the nudge instead of invalidating the whole
570
+ * prefix as the old system-head splice did. */
571
+ function deliverAdvisoryNudge(sessionId, viewTokens) {
572
+ if (modeNow() !== "advisory") {
573
+ debug(`nudge skip mode=${modeNow()} session=${sessionId}`);
574
+ return;
575
+ }
576
+ const model = capturedModelBySession.get(sessionId) ?? null;
577
+ if (model === null) {
578
+ decisionLog("advisory-nudge skip why=no-captured-model");
579
+ return;
580
+ }
581
+ if (typeof model.provider !== "string" || typeof model.model !== "string") {
582
+ decisionLog("advisory-nudge skip why=bad-model-shape");
583
+ return;
584
+ }
585
+ const cached = windowCache.get(model.provider + "/" + model.model);
586
+ let resolvedWindow; // model-resolved window, or the configured cap when unknown
587
+ if (cached === undefined) {
588
+ // Unknown: warm up single-flight but stay AUDIBLE — fall back to the
589
+ // configured cap (the same denominator the emergency tier uses), so a
590
+ // model whose metadata never resolves still gets nudged at the fold
591
+ // line instead of burning to force unseen (2026-11-14: window-unknown
592
+ // swallowed every nudge at 89% pressure).
593
+ warmWindowOnce(model, Date.now());
594
+ resolvedWindow = windowCapNow();
595
+ } else {
596
+ // Stale-but-known still gates correctly: effectiveWindowTokens binds to the
597
+ // configured cap for every model larger than it, so the pressure math is
598
+ // unchanged. Re-warm in the background instead of burning this boundary.
599
+ if (Date.now() - cached.at >= 600_000) warmWindowOnce(model, Date.now());
600
+ // Resolution failed inside the TTL: same cap fallback instead of going
601
+ // quiet (no retry hammering — the warm-up stays single-flight).
602
+ resolvedWindow = cached.windowTokens ?? windowCapNow();
603
+ }
604
+ // Bind the resolved window to the configured cap before computing pressure.
605
+ const windowTokens = effectiveWindowTokens(resolvedWindow, windowCapNow());
606
+ if (windowTokens === null) {
607
+ // Cap unavailable too (wmForceTokens=0 and no explicit cap): nothing to
608
+ // measure against — the one remaining legitimate silence.
609
+ decisionLog("advisory-nudge skip why=window-unknown");
610
+ return;
611
+ }
612
+ const wire = wireTokensOf(sessionId);
613
+ const mass = Math.max(Number.isFinite(wire) ? wire : 0, Number.isFinite(viewTokens) ? viewTokens : 0);
614
+ if (mass <= 0) {
615
+ debug(`nudge skip empty-mass session=${sessionId}`);
616
+ return;
617
+ }
618
+ const pressurePercent = (mass / windowTokens) * 100;
619
+ const nudgeLines = advisoryNudgeLines(config);
620
+ const prior = advisoryNudgeBySession.get(sessionId);
621
+ if (pressurePercent < nudgeLines.line) {
622
+ // Below the line every live nudge must go. The sweep is NOT gated
623
+ // on `prior`: restarts and registry evictions lose the episode while
624
+ // the node stays visible. The recorded seq joins the union even when
625
+ // it fell out of the 50-node scan window; nudgePrune skips seqs a
626
+ // fold already consumed, so a vanished prior.seq cannot wedge this.
627
+ const seqs = new Set(surfacePass.nudgeScan(sessionId));
628
+ if (Number.isFinite(prior?.seq)) seqs.add(prior.seq);
629
+ if (seqs.size > 0) {
630
+ if (!surfacePass.nudgePrune(sessionId, [...seqs])) {
631
+ decisionLog("advisory-nudge skip why=prune-failed pressure=" + Math.round(pressurePercent) + "%");
632
+ return;
633
+ }
634
+ advisoryNudgeBySession.delete(sessionId);
635
+ // Log the resolved denominator (cap fallback included), not
636
+ // cached.windowTokens — on a cache miss `cached` is undefined
637
+ // here and this row threw after a successful prune
638
+ // (2026-11-15T02:28:12Z advisory-nudge-failed).
639
+ decisionLog("advisory-nudge below-line pressure=" + Math.round(pressurePercent) + "% mass=" + Math.round(mass) + " resolved=" + resolvedWindow + " cap=" + windowCapNow() + " pruned=" + seqs.size);
640
+ } else if (prior !== undefined) {
641
+ advisoryNudgeBySession.delete(sessionId); // nothing visible left; close the episode quietly
642
+ }
643
+ return; // fresh episode on the next crossing
644
+ }
645
+ const critical = pressurePercent >= nudgeLines.critical;
646
+ // One-step fold target (2026-11-15 advisory rework, item 2): the status
647
+ // tool's exact recommendation inlined into the nudge, so the model can
648
+ // comply with a single ctx_trim_fold call instead of a status round trip.
649
+ // The relief cycle below reuses the same target.
650
+ const foldTarget = recommendedFoldTargetOf(sessionId);
651
+ const targetText = foldTarget === null ? null
652
+ : `[m${foldTarget.firstId}]..[m${foldTarget.lastId}] (${foldTarget.count} messages, ~${Math.round(foldTarget.tokens)} tokens) — pass firstId="${foldTarget.firstId}", lastId="${foldTarget.lastId}", count=${foldTarget.count}`;
653
+ // Three-band advisory ladder (2026-11-15 rework, item 3): high = at the
654
+ // speak line (re-pin every trimAdvisoryRepeatMs); escal = ten points
655
+ // above it (re-pin EVERY dispatch — a reminder that keeps sliding under
656
+ // the turn tail must sit at the tail); critical = the escalation line
657
+ // (fastest re-pin + the mechanical relief path below).
658
+ const escal = !critical && pressurePercent >= nudgeLines.line + 10;
659
+ const band = critical ? "critical" : escal ? "escal" : "high";
660
+ // Relief extension (item 4): critical is not the only engine fold
661
+ // trigger — three consecutive same-band speaks with no fold commit
662
+ // (prior.repeats >= 3) put an escal-band session on the SAME mechanical
663
+ // relief path, cooldown-gated like critical. Nudge-only purity survives
664
+ // for genuinely attended sessions: repeats < 3 never folds.
665
+ const reliefDue = critical || (escal && (prior?.repeats ?? 0) >= 3);
666
+ const now = Date.now();
667
+ // Critical relief ([2026-11-15]-[advisory critical relief fold]-[nudges
668
+ // alone let a session burn from the speak line to host compaction —
669
+ // turn-25: nudges ignored, 74%→99% in 28 minutes]). At the critical line
670
+ // the engine performs ONE mechanical (emergency-style) fold itself,
671
+ // cooldown-gated like the emergency tier, instead of only nudging.
672
+ // Runs ahead of the same-band nudge throttle: the relief cadence is owned
673
+ // by reliefBySession + inFlight alone. `trimCriticalRelief: false`
674
+ // restores nudge-only advisory mode.
675
+ if (reliefDue && reliefEnabledNow() && !inFlight.has(sessionId) && captures.has(sessionId)
676
+ && now - (reliefBySession.get(sessionId) ?? 0) >= EMERGENCY_COOLDOWN_MS) {
677
+ // Cooldown armed BEFORE the cycle (same pre-set semantics as the
678
+ // emergency tier): every attempt, folded or rejected, cools down.
679
+ reliefBySession.set(sessionId, now);
680
+ if (reliefBySession.size > 200) reliefBySession.delete(reliefBySession.keys().next().value);
681
+ const reliefCapture = captures.get(sessionId);
682
+ const cycleToken = {};
683
+ inFlight.set(sessionId, cycleToken);
684
+ void (async () => {
685
+ try {
686
+ if (disposedSince(now)) {
687
+ decisionLog(`session=${sessionId} advisory-relief outcome=reload-aborted tier=emergency pressure=${Math.round(pressurePercent)}% trigger=${band}`);
688
+ return;
689
+ }
690
+ const { protectedTail, protectTaskIntent, ...reliefThresholds } = thresholdsNow();
691
+ const ledger = store.ledgerOf(sessionId);
692
+ const foldable = computeFoldableRange(reliefCapture.messages, ledger, protectedTail, { protectTaskIntent });
693
+ const decision = evaluateTrigger({
694
+ foldable,
695
+ messages: reliefCapture.messages,
696
+ viewTokens: mass, // same basis as the nudge line: max(view, wire)
697
+ windowTokens,
698
+ thresholds: { ...reliefThresholds, emergencyPercent: 1 }, // force the mechanical tier through
699
+ now,
700
+ lastFoldAt: ledger.lastFoldAt,
701
+ foldsThisHour: ledger.foldsThisHour,
702
+ lastEmergencyAt: 0, // reliefBySession owns the relief cadence
703
+ ledger,
704
+ ladderRuns: { t2: [], t3: [] }, // relief is mechanical-only; no ladder cut-in
705
+ });
706
+ if (decision.action !== "emergency") {
707
+ decisionLog(`session=${sessionId} advisory-relief outcome=none reason=${decision.reason || "-"} pressure=${Math.round(pressurePercent)}% trigger=${band}`);
708
+ return;
709
+ }
710
+ const outcome = await runFoldCycle(sessionId, decision, now, cycleToken);
711
+ decisionLog(`session=${sessionId} advisory-relief outcome=${outcome} tier=emergency pressure=${Math.round(pressurePercent)}% trigger=${band}`);
712
+ if (outcome.startsWith("folded:") || outcome === "summary-unavailable" || outcome.startsWith("commit-rejected:")) {
713
+ warn(`[context-trim] ${sessionId}: advisory relief ${outcome} at ${Math.round(pressurePercent)}%`);
714
+ }
715
+ } catch (error) {
716
+ decisionLog(`session=${sessionId} advisory-relief outcome=error why=${error?.message ?? error}`);
717
+ warn(`[context-trim] ${sessionId}: advisory relief failed: ${error?.message ?? error}`);
718
+ } finally {
719
+ // Only the cycle owning the CURRENT token may release the lock.
720
+ if (inFlight.get(sessionId) === cycleToken) inFlight.delete(sessionId);
721
+ }
722
+ })();
723
+ } else if (reliefDue) {
724
+ debug(`relief gate held pressure=${Math.round(pressurePercent)}% session=${sessionId}`);
725
+ // Audible silence ([2026-11-15]-[relief skip diagnostics]): the turn-25
726
+ // incident hid here — a session sat at 81-125% critical while the gate
727
+ // held, and the only trace was a two-hour view-applied reconciliation.
728
+ // One throttled row per minute names the exact gate that held the
729
+ // relief back (cooldown / cycle in flight / no capture / disabled).
730
+ if (now - (reliefSkipLogAt.get(sessionId) ?? 0) >= 60_000) {
731
+ reliefSkipLogAt.set(sessionId, now);
732
+ if (reliefSkipLogAt.size > 200) reliefSkipLogAt.delete(reliefSkipLogAt.keys().next().value);
733
+ debug(`relief gate detail enabled=${reliefEnabledNow()} cycle=${inFlight.has(sessionId)} capture=${captures.has(sessionId)} sinceLast=${now - (reliefBySession.get(sessionId) ?? 0)}ms session=${sessionId}`);
734
+ const why = !reliefEnabledNow() ? "relief-disabled"
735
+ : inFlight.has(sessionId) ? "cycle-in-flight"
736
+ : !captures.has(sessionId) ? "no-capture"
737
+ : "cooldown";
738
+ decisionLog(`session=${sessionId} advisory-relief skip why=${why} pressure=${Math.round(pressurePercent)}%`);
739
+ }
740
+ }
741
+ // Any band CHANGE re-speaks immediately: escalation high->critical, and
742
+ // intentionally also a downgrade back into high after a partial fold.
743
+ // [2026-11-15]-[per-band tail re-pin]-[a spoken nudge slides under the
744
+ // growing turn tail and becomes background noise; the single 10-minute
745
+ // repeat let 2.5 minutes of throttled silence precede the only fold
746
+ // (decisions.log 10:19→10:22). The nudge now REPLACES itself at the new
747
+ // tail every trimAdvisoryRepeatMs (high band) / min(that, 45 s)
748
+ // (critical): prune old seq, append fresh. Counts same-band repeats for
749
+ // the terse repeat wording.]
750
+ const repeatLines = advisoryRepeatLines(config);
751
+ const repeatMs = band === "high" ? repeatLines.highMs
752
+ : band === "escal" ? 0 // every dispatch: the reminder must own the tail
753
+ : Math.min(repeatLines.highMs, repeatLines.criticalMs);
754
+ if (prior !== undefined && prior.band === band && now - prior.at < repeatMs) {
755
+ debug(`nudge throttle band=${band} age=${now - prior.at}ms repeats=${prior.repeats ?? 0} session=${sessionId}`);
756
+ return;
757
+ }
758
+ if (!surfacePass.nudgeSupported(sessionId)) {
759
+ decisionLog("advisory-nudge skip why=surface-unavailable pressure=" + Math.round(pressurePercent) + "%");
760
+ return;
761
+ }
762
+
763
+ // Replace the recorded prior nudge, or clear orphans a restart left
764
+ // behind, before speaking fresh. nudgePrune only touches seqs still
765
+ // live on the surface, so a prior a fold already consumed is a no-op.
766
+ const priorSeq = Number.isFinite(prior?.seq) ? prior.seq : null;
767
+ if (priorSeq !== null) {
768
+ if (!surfacePass.nudgePrune(sessionId, [priorSeq])) {
769
+ decisionLog("advisory-nudge skip why=prune-failed pressure=" + Math.round(pressurePercent) + "%");
770
+ return;
771
+ }
772
+ } else {
773
+ const orphans = surfacePass.nudgeScan(sessionId);
774
+ if (orphans.length > 0) {
775
+ if (!surfacePass.nudgePrune(sessionId, orphans)) {
776
+ decisionLog("advisory-nudge skip why=prune-failed pressure=" + Math.round(pressurePercent) + "%");
777
+ return;
778
+ }
779
+ decisionLog("advisory-nudge orphan-pruned seqs=" + orphans.length + " pressure=" + Math.round(pressurePercent) + "%");
780
+ }
781
+ }
782
+ // One-step targeting for the model (2026-11-15 advisory rework): the nudge
783
+ // carries the status tool's exact recommendation, so compliance is a
784
+ // single ctx_trim_fold call; without a foldable range it stays two-step.
785
+ const nextRepeats = prior !== undefined && prior.band === band ? (prior.repeats ?? 0) + 1 : 1;
786
+ const nudge = advisoryNudgeText(
787
+ Math.round(pressurePercent), critical, nudgeLines.line, nudgeLines.critical,
788
+ targetText,
789
+ nextRepeats - 1,
790
+ );
791
+ const appended = surfacePass.nudgeAppend(sessionId, nudge);
792
+ if (appended.error !== null || !Number.isFinite(appended.seq)) {
793
+ advisoryNudgeBySession.delete(sessionId); // a pruned prior must not throttle the retry
794
+ decisionLog("advisory-nudge skip why=append-failed err=" + (appended.error ?? "unknown") + " pressure=" + Math.round(pressurePercent) + "%");
795
+ return;
796
+ }
797
+ const seq = appended.seq;
798
+ advisoryNudgeBySession.delete(sessionId); // re-insert: refresh recency so eviction drops the least recently nudged
799
+ advisoryNudgeBySession.set(sessionId, { at: now, band, seq, repeats: nextRepeats });
800
+ debug(`nudge prepared seq=${seq} level=${band} pressure=${Math.round(pressurePercent)}% session=${sessionId}`);
801
+ if (advisoryNudgeBySession.size > 200) advisoryNudgeBySession.delete(advisoryNudgeBySession.keys().next().value);
802
+ decisionLog("session=" + sessionId + " advisory-nudge pressure=" + Math.round(pressurePercent) + "% mass=" + Math.round(mass) + " window=" + windowTokens + " level=" + band + " repeats=" + nextRepeats + " seq=" + seq);
803
+ }
804
+
362
805
  const captures = new Map(); // sessionId -> { messages, refs, viewTokens, model, at }
363
- const inFlight = new Set(); // sessionIds with a fold running
806
+ // sessionId -> generation token of the running fold cycle. A plain Set
807
+ // pinned a session forever once a cycle's await never settled; the
808
+ // maybeFold watchdog swaps the token so wedged cycles release the lock.
809
+ const inFlight = new Map();
810
+ // Fold-cycle watchdog: a cycle still "in flight" after this long logs a
811
+ // cycle-stuck row, releases the lock, and lets the next trigger retry.
812
+ // The ceiling must exceed the worst LEGITIMATE cycle — a raw fold can
813
+ // summarize on side AND fallback routes (2 x SUMMARY_TIMEOUT_MS = 240s) —
814
+ // so a healthy slow cycle is never preempted.
815
+ const CYCLE_WATCHDOG_MS = 300_000;
816
+ // Hard ceiling for one host model-info lookup: some llm builds never
817
+ // settle resolveModelInfo for unresolvable routes, and that wedged
818
+ // await used to pin the whole fold cycle until restart.
819
+ const WINDOW_RESOLVE_TIMEOUT_MS = 10_000;
364
820
  const windowCache = new Map(); // "provider/model" -> { windowTokens, at }
365
821
  const lastEmergencyAtBySession = new Map(); // sessionId -> epoch ms of last emergency fold (per-session cooldown)
822
+ // Advisory critical relief cooldown: sessionId -> epoch ms of the last
823
+ // relief fold. Set BEFORE the cycle (emergency semantics: a rejected
824
+ // commit cools the relief like a success, or a failing range retries at
825
+ // every dispatch forever). Bounded 200 like the sibling registries.
826
+ const reliefBySession = new Map();
827
+ const reliefSkipLogAt = new Map(); // sessionId -> epoch ms of the last relief-skip diagnostic row (60 s throttle, bounded 200)
366
828
  const lastStreamCheckAt = new Map(); // sessionId -> epoch ms of last stream-path fold check (throttle)
367
829
  const epochResetDoneBySession = new Map(); // sessionId -> ledger-stale baseline reset already done for this host epoch (cleared when a fold applies again)
368
830
 
@@ -412,6 +874,34 @@ export function applyContextTrim(ctx, config) {
412
874
  }
413
875
  }
414
876
 
877
+ /** Debug-trace verbosity gate ([2026-11-15]-[verbose debug log]): the
878
+ * turn-25 and window-unknown incidents both hid inside gates that
879
+ * outcomes-only logging never names. With trimVerboseLog on, every
880
+ * step/gate/decision path writes a debug row; off keeps key rows only. */
881
+ function verboseNow() {
882
+ try {
883
+ return readBool(config, "trimVerboseLog", false) === true;
884
+ } catch {
885
+ return false;
886
+ }
887
+ }
888
+ /** Verbose-only row: one line naming a step/gate/decision path. */
889
+ function debug(entry) {
890
+ if (!verboseNow()) return;
891
+ decisionLog("debug " + entry);
892
+ }
893
+ /** ALWAYS-ON exception detail ([2026-11-15]-[verbose debug log]):
894
+ * independent of trimVerboseLog — message + top stack frames, so a
895
+ * swallowed catch is reconstructable without reproducing it. */
896
+ function errorLog(where, error) {
897
+ try {
898
+ const stack = typeof error?.stack === "string" ? error.stack.split("\n").slice(0, 5).join(" | ") : "no-stack";
899
+ decisionLog(`error where=${where} msg=${error?.message ?? error} stack=${stack}`);
900
+ } catch {
901
+ /* observability must never throw */
902
+ }
903
+ }
904
+
415
905
  // ---- fork-state inheritance (copy-on-fork; design in fork.js header) ------
416
906
  const linksCache = { mtime: -1, map: null }; // mtime-gated fork-links map
417
907
  const inheritFirstSeen = new Map(); // sessionId -> epoch ms of first eligibility check
@@ -494,17 +984,23 @@ export function applyContextTrim(ctx, config) {
494
984
  const mode = readString(config, "trimMode", "auto");
495
985
  return mode === "off" || mode === "advisory" || mode === "auto" ? mode : "auto";
496
986
  };
987
+ const reliefEnabledNow = () => readBool(config, "trimCriticalRelief", true);
497
988
  const thresholdsNow = () => ({
498
989
  growthTokens: readNumber(config, "trimGrowthTokens", 22_500),
499
- incrementTokens: readNumber(config, "trimIncrementTokens", 25_000),
500
- pressurePercent: readNumber(config, "trimPressurePercent", 65),
501
- emergencyPercent: readNumber(config, "trimEmergencyPercent", 95),
502
- protectedTail: Math.max(0, Math.round(readNumber(config, "trimProtectedTail", 10))),
503
- maxFoldsPerHour: readNumber(config, "trimMaxFoldsPerHour", 30),
990
+ incrementTokens: readNumber(config, "trimIncrementTokens", 45_000),
991
+ // Count-typed fields integerize here (Math.round): the schema allows
992
+ // decimals (z.number()), but trigger.js gates them with Number.isInteger
993
+ // — a non-integer config value would otherwise fall back to the
994
+ // hard-coded default silently (convention mismatch, F6).
995
+ pressurePercent: readNumber(config, "trimPressurePercent", 45),
996
+ emergencyPercent: readNumber(config, "trimEmergencyPercent", 90),
997
+ protectedTail: Math.max(0, Math.round(readNumber(config, "trimProtectedTail", 6))),
998
+ maxFoldsPerHour: Math.max(1, Math.round(readNumber(config, "trimMaxFoldsPerHour", 30))),
504
999
  ladderT2Tokens: readNumber(config, "trimLadderT2Tokens", 8_000),
505
- ladderT2MinBlocks: readNumber(config, "trimLadderT2MinBlocks", 3),
1000
+ ladderT2MinBlocks: Math.max(2, Math.round(readNumber(config, "trimLadderT2MinBlocks", 3))),
506
1001
  ladderT3Tokens: readNumber(config, "trimLadderT3Tokens", 6_000),
507
- ladderT3MinBlocks: readNumber(config, "trimLadderT3MinBlocks", 2),
1002
+ ladderT3MinBlocks: Math.max(2, Math.round(readNumber(config, "trimLadderT3MinBlocks", 2))),
1003
+ protectTaskIntent: readBool(config, "trimProtectTaskIntent", true),
508
1004
  });
509
1005
  const ladderEnabledNow = () => readBool(config, "trimLadderEnabled", true);
510
1006
  /** CJK-aware summary-mass estimator for the ladder census (same units
@@ -546,10 +1042,69 @@ export function applyContextTrim(ctx, config) {
546
1042
  const windowCapNow = () => {
547
1043
  const explicit = readNumber(config, "trimWindowTokens", 0);
548
1044
  if (explicit > 0) return explicit;
549
- const force = readNumber(config, "wmForceTokens", 150_000);
1045
+ const force = readNumber(config, "wmForceTokens", 200_000);
550
1046
  return force > 0 ? force : null;
551
1047
  };
552
1048
 
1049
+ // Publish the client-badge snapshot bridge (module-level trimPressureSnapshotOf
1050
+ // reads this). Every field is recomputed per request from live config + the
1051
+ // session's captured model/window/wire — same math as the nudge line, no new
1052
+ // state. viewTokens arrives from the recent-view bridge (10-min TTL); proj
1053
+ // from the meter-projection registry. Failure of any component degrades that
1054
+ // field to null; the chip hides on available=false or window=null.
1055
+ trimPressureSnapshotFn = (sessionId) => {
1056
+ try {
1057
+ if (!enabledNow() || typeof sessionId !== "string" || sessionId === "") return { available: false };
1058
+ const model = recentTrimModelOf(sessionId, TRIM_VIEW_MAX_AGE_MS);
1059
+ let resolvedWindow = null;
1060
+ if (model !== null) {
1061
+ const cached = windowCache.get(model.provider + "/" + model.model);
1062
+ if (cached !== undefined) resolvedWindow = cached.windowTokens ?? null;
1063
+ }
1064
+ const cap = windowCapNow();
1065
+ const windowTokens = resolvedWindow !== null ? effectiveWindowTokens(resolvedWindow, cap) : cap;
1066
+ if (windowTokens === null || windowTokens <= 0) return { available: false };
1067
+ const viewTokens = recentTrimViewTokensOf(sessionId, TRIM_VIEW_MAX_AGE_MS);
1068
+ const wire = wireTokensOf(sessionId);
1069
+ const mass = Math.max(Number.isFinite(wire) ? wire : 0, Number.isFinite(viewTokens) ? viewTokens : 0);
1070
+ const projectedEntry = trimProjectedBySession.get(sessionId);
1071
+ const proj = projectedEntry !== undefined && Date.now() - projectedEntry.at <= TRIM_VIEW_MAX_AGE_MS ? Math.round(projectedEntry.totalTokens) : null;
1072
+ const lines = advisoryNudgeLines(config);
1073
+ const pressure = mass > 0 ? (mass / windowTokens) * 100 : 0;
1074
+ // Cooldown story must match the GATE, not just the timestamp
1075
+ // ([2026-11-15]-[relief snapshot coherence]): the gate is
1076
+ // enabled + advisory + no cycle in flight + cooldown elapsed;
1077
+ // reporting only the arithmetic let the chip claim "ready"
1078
+ // while the engine would still skip (cycle in flight).
1079
+ const reliefAt = reliefBySession.get(sessionId) ?? 0;
1080
+ const reliefCooldownRemaining = reliefEnabledNow() && modeNow() === "advisory"
1081
+ ? Math.max(0, EMERGENCY_COOLDOWN_MS - (Date.now() - reliefAt))
1082
+ : null;
1083
+ const reliefReady = reliefCooldownRemaining !== null
1084
+ && reliefCooldownRemaining === 0
1085
+ && !inFlight.has(sessionId)
1086
+ && captures.has(sessionId);
1087
+
1088
+ return {
1089
+ available: true,
1090
+ mode: modeNow(),
1091
+ mass: Math.round(mass),
1092
+ window: windowTokens,
1093
+ pressure: Math.round(pressure),
1094
+ view: viewTokens !== null ? Math.round(viewTokens) : null,
1095
+ wire: Number.isFinite(wire) ? Math.round(wire) : null,
1096
+ proj,
1097
+ advisoryLine: lines.line,
1098
+ criticalLine: lines.critical,
1099
+ reliefEnabled: reliefEnabledNow(),
1100
+ reliefCooldownRemaining,
1101
+ reliefReady,
1102
+ };
1103
+ } catch {
1104
+ return { available: false };
1105
+ }
1106
+ };
1107
+
553
1108
  /** Session id for a stream call: options.sessionId, else the current
554
1109
  * initiator's session (try/catch — host internals shift). */
555
1110
  function sessionIdOf(options) {
@@ -573,20 +1128,41 @@ export function applyContextTrim(ctx, config) {
573
1128
  return effectiveWindowTokens(resolved, windowCapNow());
574
1129
  }
575
1130
 
576
- /** Async raw-window resolution (fills the cache; null when unknown). */
1131
+ /** Async raw-window resolution (fills the cache; null when unknown).
1132
+ * Two fixes over the historical behavior: the host contract is
1133
+ * resolveModelInfo(provider, model) — two strings; passing the model
1134
+ * object threw NO_ADAPTER on every route, so every decision row ran on
1135
+ * the cap fallback. And the lookup is raced against a hard timeout:
1136
+ * a never-settling promise must not pin the fold cycle. */
577
1137
  async function resolveRawWindow(model, key, now) {
1138
+ let timer = null;
578
1139
  try {
579
1140
  const llm = ctx.get("llm", false);
580
- const info = await llm?.resolveModelInfo?.(model);
1141
+ const lookup = Promise.resolve(llm?.resolveModelInfo?.(model.provider, model.model));
1142
+ const info = await Promise.race([
1143
+ lookup,
1144
+ new Promise((_, reject) => {
1145
+ timer = setTimeout(() => reject(new Error(`no settle within ${WINDOW_RESOLVE_TIMEOUT_MS}ms`)), WINDOW_RESOLVE_TIMEOUT_MS);
1146
+ timer.unref?.();
1147
+ }),
1148
+ ]);
581
1149
  const windowTokens = info?.context?.contextWindow;
582
1150
  const value = Number.isFinite(windowTokens) && windowTokens > 0 ? windowTokens : null;
1151
+ if (value === null) decisionLog(`window-resolve-failed key=${key} why=no context metadata`);
583
1152
  windowCache.set(key, { windowTokens: value, at: now });
584
1153
  if (windowCache.size > 200) windowCache.delete(windowCache.keys().next().value);
1154
+ if (value !== null) decisionLog(`window-resolved key=${key} window=${value}`);
585
1155
  return value;
586
- } catch {
1156
+ } catch (error) {
587
1157
  windowCache.set(key, { windowTokens: null, at: now });
588
1158
  if (windowCache.size > 200) windowCache.delete(windowCache.keys().next().value);
1159
+ // One row per model per 10-min cache window (plus rare concurrent
1160
+ // misses inside the 10s race window): a silently failing resolution
1161
+ // is what hid the wrong-arity call for months.
1162
+ decisionLog(`window-resolve-failed key=${key} why=${error?.message ?? error}`);
589
1163
  return null;
1164
+ } finally {
1165
+ if (timer !== null) clearTimeout(timer);
590
1166
  }
591
1167
  }
592
1168
 
@@ -595,7 +1171,8 @@ export function applyContextTrim(ctx, config) {
595
1171
  function captureOf(sessionId, messages, model, viewTokens) {
596
1172
  captures.set(sessionId, { messages: messages.slice(), model, viewTokens, at: Date.now() });
597
1173
  if (model !== null && typeof model === "object" && typeof model.provider === "string" && typeof model.model === "string") {
598
- capturedModelBySession.set(sessionId, { provider: model.provider, model: model.model, at: Date.now() });
1174
+ capturedModelBySession.set(sessionId, { provider: model.provider, model: model.model, at: Date.now() });
1175
+ if (!windowCache.has(model.provider + "/" + model.model)) warmWindowOnce(model, Date.now());
599
1176
  if (capturedModelBySession.size > 200) capturedModelBySession.delete(capturedModelBySession.keys().next().value);
600
1177
  }
601
1178
  // keep the capture map bounded (200 sessions)
@@ -636,10 +1213,16 @@ export function applyContextTrim(ctx, config) {
636
1213
  // previous response's usage) makes the view-vs-wire divergence visible
637
1214
  // instead of leaving the view estimate as the only lullaby in the log.
638
1215
  const wireTokens = wireTokensOf(sessionId);
1216
+ // Projection truth in the row: the host meter's projected NEXT-prompt
1217
+ // size (context-panel domain) sampled at the last idle boundary. Diverges
1218
+ // from wire by design — wire is the last PAID prompt, proj estimates the
1219
+ // next one — so the two columns bracket the real pressure band.
1220
+ const projectedEntry = trimProjectedBySession.get(sessionId);
1221
+ const projectedTokens = projectedEntry !== undefined && Date.now() - projectedEntry.at <= TRIM_VIEW_MAX_AGE_MS ? Math.round(projectedEntry.totalTokens) : null;
639
1222
  // Per-request application evidence: whether the ledger's blocks actually
640
1223
  // locate inside THIS re-send (the host may compact/replace history, in
641
1224
  // which case applied=0 explains a stuck-high view without any failure).
642
- decisionLog(`session=${sessionId} view-applied applied=${view.applied} foldedMsgs=${view.foldedMessages} view=${Math.round(viewTokens)} blocks=${blocksCount} injected=${injected}${wireTokens === null ? "" : ` wire=${wireTokens}`}`);
1225
+ decisionLog(`session=${sessionId} view-applied applied=${view.applied} foldedMsgs=${view.foldedMessages} view=${Math.round(viewTokens)} blocks=${blocksCount} injected=${injected}${wireTokens === null ? "" : ` wire=${wireTokens}`}${projectedTokens === null ? "" : ` proj=${projectedTokens}`}${MODULE_BUILD_STAMP > 0 ? ` b=${MODULE_BUILD_STAMP}` : ""}`);
643
1226
  // Side-dispatch guard: auxiliary llm calls that inherit the session id
644
1227
  // (web-search summaries, title generation, fold-span material) present
645
1228
  // tiny arrays in which the ledger cannot locate. Accepting them as the
@@ -744,9 +1327,12 @@ export function applyContextTrim(ctx, config) {
744
1327
 
745
1328
  /** Run one fold cycle for a session (called at the idle boundary ONLY).
746
1329
  * Returns a short outcome string for logging. */
747
- async function runFoldCycle(sessionId, decision, cycleStartedAt) {
1330
+ async function runFoldCycle(sessionId, decision, cycleStartedAt, cycleToken) {
748
1331
  const capture = captures.get(sessionId);
749
- if (capture === undefined) return "no-capture";
1332
+ if (capture === undefined) {
1333
+ debug(`cycle step no-capture session=${sessionId}`);
1334
+ return "no-capture";
1335
+ }
750
1336
  const { protectedTail } = thresholdsNow();
751
1337
  // Ladder cycle (docs/ladder-fold.md): distill the decision's consumed
752
1338
  // run of block summaries into ONE replacement. Never mechanical — a
@@ -770,6 +1356,7 @@ export function applyContextTrim(ctx, config) {
770
1356
  // Ladder folds never degrade to mechanical (docs/ladder-fold.md):
771
1357
  // after both routes fail the cycle stays fail-open with backoff.
772
1358
  if (summary === null) return disposedSince(cycleStartedAt) ? "reload-aborted" : "summary-unavailable"; // skip cycle, fail-open; a dispose cut is not a model failure
1359
+ if (inFlight.get(sessionId) !== cycleToken) return "superseded"; // watchdog released us; a retry owns the session
773
1360
  const result = store.commitFold(
774
1361
  sessionId,
775
1362
  { consumes: decision.consumes.slice(), summary, kind: "summary", tier: decision.tier === "ladder-3" ? 3 : 2, approxTokens },
@@ -790,6 +1377,7 @@ export function applyContextTrim(ctx, config) {
790
1377
  const refs = slice.map((message, i) => refOf(message, startIdx + i));
791
1378
  const count = slice.length;
792
1379
  const approxTokens = estimateTokensOf(slice);
1380
+ debug(`cycle step begin kind=${decision.kind} tier=${decision.tier ?? "-"} range=${startIdx}..${endIdx} session=${sessionId}`);
793
1381
  let summary = null;
794
1382
  let kind = "summary";
795
1383
  let degraded = false; // set when a PRESSURE fold falls back to mechanical
@@ -812,6 +1400,7 @@ export function applyContextTrim(ctx, config) {
812
1400
  onFail: (why) => decisionLog(`session=${sessionId} summary-fail route=${tag} why=${why} msgs=${count} ~${approxTokens}t`),
813
1401
  onNotice: (why) => decisionLog(`session=${sessionId} summary-retry route=${tag} why=${why} msgs=${count} ~${approxTokens}t`),
814
1402
  });
1403
+ debug(`cycle step summarize route=${route ? `${route.provider}/${route.model}` : "none"} fallback=${fallback ? `${fallback.provider}/${fallback.model}` : "none"} msgs=${count} ~${approxTokens}t session=${sessionId}`);
815
1404
  summary = route !== null ? await summarize(route, "side") : null;
816
1405
  if (summary === null && fallback !== null && !sameRoute(fallback, route)) summary = await summarize(fallback, "fallback");
817
1406
  // Last resort for PRESSURE folds only: relief beats fidelity when
@@ -828,6 +1417,8 @@ export function applyContextTrim(ctx, config) {
828
1417
  if (summary === null) return disposedSince(cycleStartedAt) ? "reload-aborted" : "summary-unavailable"; // skip cycle, fail-open; a dispose cut is not a model failure
829
1418
  }
830
1419
  const positions = refPositions(capture.messages);
1420
+ if (inFlight.get(sessionId) !== cycleToken) return "superseded"; // watchdog released us; a retry owns the session
1421
+ debug(`cycle step commit kind=${kind} tier=${decision.tier} msgs=${count} ~${approxTokens}t session=${sessionId}`);
831
1422
  const result = store.commitFold(
832
1423
  sessionId,
833
1424
  { refRange: { firstId: refs[0], lastId: refs[refs.length - 1] }, count, approxTokens, summary, kind, tier: decision.tier },
@@ -852,15 +1443,28 @@ export function applyContextTrim(ctx, config) {
852
1443
  async function maybeFold(agent, via = "idle") {
853
1444
  const sessionId = agent;
854
1445
  if (sessionId === null || sessionId === undefined || sessionId === "") return;
855
- if (!enabledNow() || modeNow() !== "auto") return;
856
- if (inFlight.has(sessionId)) return;
1446
+ if (!enabledNow() || modeNow() !== "auto") {
1447
+ debug(`session=${sessionId} via=${via} skip enabled=${enabledNow()} mode=${modeNow()}`);
1448
+ return;
1449
+ }
1450
+ if (inFlight.has(sessionId)) {
1451
+ debug(`session=${sessionId} via=${via} skip cycle-in-flight`);
1452
+ return;
1453
+ }
857
1454
  // Failure backoff: a session whose recent cycles failed stays quiet
858
1455
  // for 60s*2^(n-1) (cap 15 min) instead of re-firing at every idle
859
1456
  // boundary and stream check. Silent gate — the outcome row that armed
860
1457
  // the cooldown is already in decisions.log.
861
- if (foldFailureCooldownOf(sessionId) > 0) return;
1458
+ const failureCooldown = foldFailureCooldownOf(sessionId);
1459
+ if (failureCooldown > 0) {
1460
+ debug(`session=${sessionId} via=${via} skip failure-backoff remaining=${failureCooldown}ms`);
1461
+ return;
1462
+ }
862
1463
  const capture = captures.get(sessionId);
863
- if (capture === undefined) return;
1464
+ if (capture === undefined) {
1465
+ debug(`session=${sessionId} via=${via} skip no-capture`);
1466
+ return;
1467
+ }
864
1468
  // Injection-unavailable: the last dispatch could not carry the view
865
1469
  // (frozen options AND messages — the wire always ships raw history).
866
1470
  // Folding is pointless ONLY when NEITHER channel can reach the wire:
@@ -874,15 +1478,28 @@ export function applyContextTrim(ctx, config) {
874
1478
  decisionLog(`session=${sessionId} via=${via} action=none reason=injection-unavailable view=${Math.round(capture.viewTokens)}`);
875
1479
  return;
876
1480
  }
877
- inFlight.add(sessionId);
878
1481
  const cycleStartedAt = Date.now();
1482
+ // Generation token lock (was a plain Set): a cycle wedged on an await
1483
+ // that never settles used to pin this session's fold path until
1484
+ // restart. The watchdog logs the wedge and releases the lock; an
1485
+ // abandoned cycle returns "superseded" at its commit guard, and its
1486
+ // finally can no longer delete a retry's entry.
1487
+ const cycleToken = {};
1488
+ inFlight.set(sessionId, cycleToken);
1489
+ const cycleWatchdog = setTimeout(() => {
1490
+ if (inFlight.get(sessionId) !== cycleToken) return;
1491
+ inFlight.delete(sessionId);
1492
+ decisionLog(`session=${sessionId} via=${via} outcome=cycle-stuck elapsedMs=${Date.now() - cycleStartedAt}`);
1493
+ warn(`[context-trim] ${sessionId}: fold cycle stuck >${CYCLE_WATCHDOG_MS}ms — lock released for the next trigger`);
1494
+ }, CYCLE_WATCHDOG_MS);
1495
+ cycleWatchdog.unref?.();
879
1496
  // Hoisted for the catch below: a dispose-cut throw may land before the
880
1497
  // trigger ever ran, and the catch must not touch a dead binding.
881
1498
  let decision = null;
882
1499
  try {
883
- const { protectedTail, ...thresholds } = thresholdsNow();
1500
+ const { protectedTail, protectTaskIntent, ...thresholds } = thresholdsNow();
884
1501
  const ledger = store.ledgerOf(sessionId);
885
- const foldable = computeFoldableRange(capture.messages, ledger, protectedTail);
1502
+ const foldable = computeFoldableRange(capture.messages, ledger, protectedTail, { protectTaskIntent });
886
1503
  const windowTokens = await windowTokensFor(capture.model);
887
1504
  // Pressure tiers run on the REAL prompt size when the tally is known:
888
1505
  // max(view, wire). The view estimate misses the system/context
@@ -905,12 +1522,15 @@ export function applyContextTrim(ctx, config) {
905
1522
  });
906
1523
  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"}`);
907
1524
  if (decision.action === "none") return;
908
- const outcome = await runFoldCycle(sessionId, decision, cycleStartedAt);
1525
+ const outcome = await runFoldCycle(sessionId, decision, cycleStartedAt, cycleToken);
909
1526
  // Failure ladder: commit resets it; a summarize/commit failure
910
1527
  // climbs it (the no-backoff loop this closes — those outcomes
911
1528
  // advance no cadence gate on their own). A ladder run whose
912
1529
  // consumed blocks vanished between trigger and commit is the same
913
1530
  // family: without backoff it would re-fire at every boundary.
1531
+ // "superseded" (watchdog released the lock mid-cycle) and neutral
1532
+ // skips advance no ladder either — the retry that owns the session
1533
+ // is judged alone.
914
1534
  if (outcome.startsWith("folded:")) clearFoldFailure(sessionId);
915
1535
  else if (outcome === "summary-unavailable" || outcome.startsWith("commit-rejected:") || outcome === "ladder-consumed-missing") recordFoldFailure(sessionId);
916
1536
  // Outcome lands in the decision log too: warn() has no reachable
@@ -923,11 +1543,15 @@ export function applyContextTrim(ctx, config) {
923
1543
  // that is a reload cut, not a model failure: log it and skip the ladder.
924
1544
  if (disposedSince(cycleStartedAt)) decisionLog(`session=${sessionId} outcome=reload-aborted tier=${decision?.tier ?? "-"}`);
925
1545
  else {
1546
+ errorLog("fold-cycle", error);
926
1547
  recordFoldFailure(sessionId); // a thrown cycle counts as failed too
927
1548
  warn(`[context-trim] fold cycle failed: ${error?.message ?? error}`);
928
1549
  }
929
1550
  } finally {
930
- inFlight.delete(sessionId);
1551
+ clearTimeout(cycleWatchdog);
1552
+ // Only the cycle owning the CURRENT token may release the lock —
1553
+ // a watchdog-superseded wedged cycle must not delete a retry's entry.
1554
+ if (inFlight.get(sessionId) === cycleToken) inFlight.delete(sessionId);
931
1555
  }
932
1556
  }
933
1557
 
@@ -953,6 +1577,9 @@ export function applyContextTrim(ctx, config) {
953
1577
  // agent }) — derive the session id per host/context-watch.js:122.
954
1578
  const sessionId = agentSessionIdOf(payload?.agent);
955
1579
  if (sessionId === null) return;
1580
+ // Meter projection sample: host meter's projected NEXT-prompt size
1581
+ // (the context panel's figure) for the view-applied proj=<n> column.
1582
+ noteTrimProjectedTokens(sessionId, payload?.agent, ctx);
956
1583
  // Fold first, then the surface-replace idle sweep (design
957
1584
  // docs/surface-fold.md): a block committed by this idle pass
958
1585
  // ships on the same boundary.
@@ -962,7 +1589,12 @@ export function applyContextTrim(ctx, config) {
962
1589
  } catch {
963
1590
  /* sweep must never break the idle hook */
964
1591
  }
965
- });
1592
+ }).catch((error) => {
1593
+ // Parity with the stream path: maybeFold is catch-total by design,
1594
+ // but a dispose-cut throw or a future regression must not become
1595
+ // an unhandled rejection on the void'd chain.
1596
+ errorLog("fold-idle", error);
1597
+ });
966
1598
  } catch {
967
1599
  /* idle hook must never throw */
968
1600
  }
@@ -1037,19 +1669,27 @@ export function applyContextTrim(ctx, config) {
1037
1669
  name: "ctx_trim_status",
1038
1670
  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.",
1039
1671
  parameters: { type: "object", properties: {}, additionalProperties: false },
1040
- required: [],
1672
+ // Register-level shape: bare ctx.tools.register runs no defineTool DSL
1673
+ // compilation and REJECTS a definition without an output block — the
1674
+ // missing block silently killed both tools in every session (native
1675
+ // and PTC). The execute bodies return plain strings, rendered as one
1676
+ // text block.
1677
+ output: {
1678
+ schema: { type: "string" },
1679
+ render: (_args, value) => [{ type: "text", text: value }],
1680
+ },
1041
1681
  async execute() {
1042
1682
  const sessionId = sessionIdOf({});
1043
1683
  if (sessionId === null) return "No session context available.";
1044
1684
  if (!enabledNow()) return "Context trimming is disabled for this deployment.";
1045
1685
  const ledger = store.ledgerOf(sessionId);
1046
1686
  const capture = captures.get(sessionId);
1047
- const { protectedTail } = thresholdsNow();
1687
+ const { protectedTail, protectTaskIntent } = thresholdsNow();
1048
1688
  const lines = [`ctx_trim_status — mode: ${modeNow()} (enabled: ${enabledNow()})`];
1049
1689
  lines.push(...renderStatusLedger(ledger));
1050
1690
  // The raw range a fold would cover next, with its true size.
1051
1691
  if (capture !== undefined) {
1052
- const foldable = computeFoldableRange(capture.messages, ledger, protectedTail);
1692
+ const foldable = computeFoldableRange(capture.messages, ledger, protectedTail, { protectTaskIntent });
1053
1693
  if (foldable !== null) {
1054
1694
  const firstRef = refOf(capture.messages[foldable.startIdx], foldable.startIdx);
1055
1695
  const lastRef = refOf(capture.messages[foldable.endIdx], foldable.endIdx);
@@ -1087,6 +1727,11 @@ export function applyContextTrim(ctx, config) {
1087
1727
  required: ["firstId", "lastId", "count", "summary"],
1088
1728
  additionalProperties: false,
1089
1729
  },
1730
+ // Register-level output block — see ctx_trim_status above.
1731
+ output: {
1732
+ schema: { type: "string" },
1733
+ render: (_args, value) => [{ type: "text", text: value }],
1734
+ },
1090
1735
  async execute(args) {
1091
1736
  const sessionId = sessionIdOf({});
1092
1737
  if (sessionId === null) return "No session context available.";
@@ -1094,8 +1739,26 @@ export function applyContextTrim(ctx, config) {
1094
1739
  const capture = captures.get(sessionId);
1095
1740
  if (capture === undefined) return "No captured history yet — fold is unavailable until the next model call.";
1096
1741
  const positions = refPositions(capture.messages);
1097
- const firstList = positions.get(String(args?.firstId ?? ""));
1098
- const lastList = positions.get(String(args?.lastId ?? ""));
1742
+ // Ref normalization ([2026-11-15]-[m-prefix fold refs]): ctx_trim_status
1743
+ // displays anchors in the protocol's [m...] form — "m" prepended to
1744
+ // refOf's raw id/hash — and its description tells the model to pass
1745
+ // exactly that form. The lookup map is keyed by the RAW ref, so an
1746
+ // m-prefixed argument (the only form the model can copy) never matched:
1747
+ // every model-driven fold failed with "Refs not found" (found live in
1748
+ // the PTC runtime — status listed a 234-message range and the fold
1749
+ // rejected it four times in a row). Resolve both forms: try the
1750
+ // argument as given, then with one leading "m" stripped.
1751
+ const resolveKey = (raw) => {
1752
+ const given = String(raw ?? "");
1753
+ if (positions.has(given)) return given;
1754
+ if (given.startsWith("m") && positions.has(given.slice(1))) return given.slice(1);
1755
+ return given;
1756
+ };
1757
+ const resolveRef = (raw) => positions.get(resolveKey(raw));
1758
+
1759
+ const firstList = resolveRef(args?.firstId);
1760
+ const lastList = resolveRef(args?.lastId);
1761
+
1099
1762
  if (firstList === undefined || lastList === undefined) return "Refs not found in the current history — re-check with ctx_trim_status.";
1100
1763
  // Count-anchored resolution: duplicate refs (identical content)
1101
1764
  // make the first/last occurrence lists ambiguous — anchor on the
@@ -1110,6 +1773,15 @@ export function applyContextTrim(ctx, config) {
1110
1773
  }
1111
1774
  if (firstIdx < 0) { firstIdx = firstList[0]; lastIdx = lastList[lastList.length - 1]; }
1112
1775
  if (lastIdx < firstIdx) return "lastId sorts before firstId in the current history.";
1776
+ // Prior fold summaries are never raw-fold material: a T1 fold
1777
+ // covering an acp_summary_* node would shadow the replacement
1778
+ // node and break the ladder's consumed-run bookkeeping.
1779
+ // Summaries consolidate only through ladder T2/T3 distillation.
1780
+ for (let i = firstIdx; i <= lastIdx; i++) {
1781
+ if (isFoldSummaryMessage(capture.messages[i])) {
1782
+ return "Range covers a previous fold's summary (acp_summary_*) — summaries are preserved and consolidate via T2/T3 ladder; pick a range that excludes them.";
1783
+ }
1784
+ }
1113
1785
  const { protectedTail } = thresholdsNow();
1114
1786
  if (lastIdx >= capture.messages.length - Math.max(0, protectedTail)) return `Range reaches into the protected tail (last ${protectedTail} messages are never folded).`;
1115
1787
  if (!rangeIsSegmentAligned(capture.messages, firstIdx, lastIdx)) return "Range splits a tool-call pair — fold whole assistant/tool segments (extend firstId/lastId to the segment boundaries).";
@@ -1117,7 +1789,12 @@ export function applyContextTrim(ctx, config) {
1117
1789
  const result = store.commitFold(
1118
1790
  sessionId,
1119
1791
  {
1120
- refRange: { firstId: String(args.firstId), lastId: String(args.lastId) },
1792
+ // Resolved refs: strip the display "m" prefix so commitFold's raw-key
1793
+ // posMap lookup matches (same normalization as resolveRef above).
1794
+ refRange: {
1795
+ firstId: resolveKey(args.firstId),
1796
+ lastId: resolveKey(args.lastId),
1797
+ },
1121
1798
  count,
1122
1799
  approxTokens: estimateTokensOf(capture.messages, firstIdx, lastIdx),
1123
1800
  summary: String(args.summary ?? ""),
@@ -1127,6 +1804,11 @@ export function applyContextTrim(ctx, config) {
1127
1804
  positions,
1128
1805
  );
1129
1806
  if (!result.ok) return `Fold rejected: ${result.reason}.`;
1807
+ // Auditable commit ([2026-11-15]-[tool-fold observability]): the only
1808
+ // prior trace was the fold-commit SURFACE sweep; a sweep that failed
1809
+ // or stayed pending left the commit itself invisible in the log.
1810
+ decisionLog(`session=${sessionId} via=tool action=fold tier=advisory block=${result.block.id} msgs=${count} tokens=~${result.block.approxTokens}`);
1811
+
1130
1812
  surfacePass.captureFoldSeqs(sessionId, result.block);
1131
1813
  // Growth-gate baseline pair: pre-fold view (banner display) plus the
1132
1814
  // post-fold low (tier-a anchor — keeps the gate armed after folds
@@ -1134,7 +1816,18 @@ export function applyContextTrim(ctx, config) {
1134
1816
  const lowLedger = store.ledgerOf(sessionId);
1135
1817
  const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail: thresholdsNow().protectedTail }).messages);
1136
1818
  store.recordFoldBaseline(sessionId, capture.viewTokens, lowView);
1137
- return `Folded ${count} messages into block ${result.block.id}. The view applies on the next model call.`;
1819
+ // Ship the fresh block onto the session surface NOW instead of waiting for
1820
+ // the next idle/turn-stopping boundary: a fold committed mid-turn used to
1821
+ // stay meter-only for the rest of the turn (2026-11-14 live test: two
1822
+ // tool-path folds left the wire carrying raw history for ~11 min at 97%
1823
+ // pressure — injected=none on frozen dispatches, both blocks pending).
1824
+ // The sweep's own guards (enabled flag, capable session, compaction lock,
1825
+ // per-block validation) fail closed to pending; the boundary sweeps
1826
+ // remain the closure pass.
1827
+ void surfacePass.sweep(sessionId, "fold-commit").catch((error) => {
1828
+ decisionLog(`session=${sessionId} surface-replace failed via=fold-commit why=${error?.message ?? error}`);
1829
+ });
1830
+ return `Folded ${count} messages into block ${result.block.id}. An immediate surface ship was queued (via=fold-commit): when it lands, even frozen dispatches carry the reduced history; if the surface cannot accept it right now the block stays pending and the boundary sweeps retry.`;
1138
1831
  },
1139
1832
  });
1140
1833
  } catch (error) {
@@ -1152,11 +1845,24 @@ export function applyContextTrim(ctx, config) {
1152
1845
  if (applied !== null) {
1153
1846
  viewTokens = applied.viewTokens;
1154
1847
  mainDispatch = applied.sideDispatch !== true;
1848
+ if (mainDispatch) {
1849
+ // Advisory pressure nudge (modeNow gates inside): the model's
1850
+ // own fold is advisory mode's primary relief channel, plus the
1851
+ // cooldown-gated critical relief fold below — speak at the fold
1852
+ // line. Fail-open, never breaks the dispatch.
1853
+ try {
1854
+ deliverAdvisoryNudge(applied.sessionId, applied.viewTokens);
1855
+ } catch (error) {
1856
+ decisionLog(`advisory-nudge-failed why=${error?.message ?? error}`);
1857
+ errorLog("advisory-nudge", error);
1858
+ }
1859
+ }
1155
1860
  }
1156
1861
  }
1157
1862
  } catch (error) {
1158
1863
  decisionLog(`view-build-failed why=${error?.message ?? error}`);
1159
1864
  warn(`[context-trim] view build failed: ${error?.message ?? error}`);
1865
+ errorLog("view-build", error);
1160
1866
  }
1161
1867
  const result = next();
1162
1868
  let wrappedSession = null;
@@ -1169,10 +1875,24 @@ export function applyContextTrim(ctx, config) {
1169
1875
  if (usage !== null) {
1170
1876
  metrics.recordUsage(wrappedSession, usage);
1171
1877
  if (mainDispatch) noteTrimWireTokens(wrappedSession, usage);
1878
+ // Second proj sample point ([2026-11-15]-[proj sampling gaps]):
1879
+ // the agent/status idle boundary alone left proj=null on most rows —
1880
+ // long-running turns never go idle mid-flight, so the view-applied
1881
+ // column went dark exactly when the divergence data mattered. At
1882
+ // response end the agent handle is resolvable via
1883
+ // ctx.agents.get(sessionId) (the same service subagents.js reads);
1884
+ // unreachable => the sample silently skips, as designed.
1885
+ try {
1886
+ noteTrimProjectedTokens(wrappedSession, ctx.agents?.get?.(wrappedSession), ctx);
1887
+ } catch {
1888
+ /* observability must never break the stream */
1889
+ }
1172
1890
  }
1173
1891
  });
1174
1892
  } catch (error) {
1175
1893
  warn(`[context-trim] stream wrap failed: ${error?.message ?? error}`);
1894
+ decisionLog(`stream-wrap-failed why=${error?.message ?? error}`);
1895
+ errorLog("stream-wrap", error);
1176
1896
  return result;
1177
1897
  } finally {
1178
1898
  // Fallback fold check on the stream path: the agent/status idle event
@@ -1190,17 +1910,32 @@ export function applyContextTrim(ctx, config) {
1190
1910
  if (nowMs - (lastStreamCheckAt.get(wrappedSession) ?? 0) >= 60_000) {
1191
1911
  lastStreamCheckAt.set(wrappedSession, nowMs);
1192
1912
  if (lastStreamCheckAt.size > 200) lastStreamCheckAt.delete(lastStreamCheckAt.keys().next().value);
1193
- void maybeFold(wrappedSession, "stream").finally(() => surfacePass.sweep(wrappedSession, "stream"));
1913
+ void maybeFold(wrappedSession, "stream").finally(() => {
1914
+ try {
1915
+ void surfacePass.sweep(wrappedSession, "stream");
1916
+ } catch (error) {
1917
+ errorLog("surface-sweep-stream", error);
1918
+ }
1919
+ }).catch((error) => {
1920
+ // maybeFold never rejects by design (its own catch is total), but a
1921
+ // dispose-cut throw or a future regression must not become an
1922
+ // unhandled rejection: the .finally here chains the sweep, so the
1923
+ // void'd chain needs its own terminal handler (parity with the
1924
+ // fold-commit sweep's .catch below).
1925
+ errorLog("fold-stream", error);
1926
+ });
1194
1927
  }
1195
1928
  }
1196
- } catch {
1929
+ } catch (error) {
1197
1930
  /* fallback must never break the stream */
1931
+ errorLog("stream-fallback", error);
1198
1932
  }
1199
1933
  }
1200
1934
  }
1201
1935
 
1202
1936
  try {
1203
1937
  ctx.inject(["llm"], (llmCtx) => {
1938
+ const disposeListenerOf = new Map(); // "llm/stream" -> the on() disposer of the current attach
1204
1939
  const attach = () => {
1205
1940
  try {
1206
1941
  // Global + prepend: the cordis dispatch filter silently drops NON-global
@@ -1209,10 +1944,23 @@ export function applyContextTrim(ctx, config) {
1209
1944
  // subtree) — that silenced the whole stream path for a resumed session
1210
1945
  // while fresh sessions kept logging. Stock consumers (dsh-session-title)
1211
1946
  // register exactly this way.
1212
- llmCtx.on?.("llm/stream", streamListener, { global: true, prepend: true });
1213
- decisionLog(`llm-attached hasOn=${typeof llmCtx.on} hasOff=${typeof llmCtx.off} hasEffect=${typeof llmCtx.effect} pid=${process.pid}`);
1947
+ // [2026-11-15]-[llm-attach diagnostics proxy trap]: reading llmCtx.off
1948
+ // through the context proxy THROWS on the cordis build the harness
1949
+ // ships (the events mixin exposes on/once/emit/… but NOT off —
1950
+ // vendor/cordis/src/reflect.ts get-trap: `cannot get property "off"
1951
+ // without inject`). `?.` cannot defend against a throwing getter, so
1952
+ // the old SUCCESS row itself raised, landed as llm-attach-failed, and
1953
+ // the true success line vanished (2026-11-15 06:29Z). on()'s returned
1954
+ // disposer is the unsubscribe API; off never existed here.
1955
+ const disposeListener = llmCtx.on?.("llm/stream", streamListener, { global: true, prepend: true });
1956
+ disposeListenerOf.set("llm/stream", disposeListener);
1957
+ let hasOff = false;
1958
+ try { hasOff = typeof llmCtx.off === "function"; } catch { hasOff = false; }
1959
+ decisionLog(`llm-attached hasOn=${typeof llmCtx.on} hasOff=${hasOff} hasDisposer=${typeof disposeListener} hasEffect=${typeof llmCtx.effect} pid=${process.pid}`);
1214
1960
  } catch (error) {
1215
1961
  warn(`[context-trim] llm/stream attach failed: ${error?.message ?? error}`);
1962
+ decisionLog(`llm-attach-failed why=${error?.message ?? error}`);
1963
+ errorLog("llm-attach", error);
1216
1964
  }
1217
1965
  };
1218
1966
  // Reload-safe release (applyIntent precedent): undo the listener
@@ -1221,14 +1969,21 @@ export function applyContextTrim(ctx, config) {
1221
1969
  const effect = llmCtx.effect;
1222
1970
  if (typeof effect === "function") {
1223
1971
  effect(function* () {
1224
- yield () => {
1225
- try {
1226
- llmCtx.off?.("llm/stream", streamListener);
1227
- } catch {
1228
- /* best effort */
1229
- }
1230
- };
1231
- }, "context-trim-stream-listener");
1972
+ yield () => {
1973
+ try {
1974
+ // The on() disposer is the only real unsubscribe: ctx.off is not
1975
+ // exposed by this cordis build (see the attach comment above) — the
1976
+ // old llmCtx.off?.() call threw on EVERY reload teardown and the
1977
+ // cleanup silently never ran (fiber-unload auto-dispose was the
1978
+ // only thing that actually removed the listener).
1979
+ const dispose = disposeListenerOf.get("llm/stream");
1980
+ disposeListenerOf.delete("llm/stream");
1981
+ if (typeof dispose === "function") dispose();
1982
+ } catch {
1983
+ /* best effort */
1984
+ }
1985
+ };
1986
+ }, "context-trim-stream-listener");
1232
1987
  }
1233
1988
  } catch {
1234
1989
  /* effect registration is best-effort */
@@ -1239,5 +1994,5 @@ export function applyContextTrim(ctx, config) {
1239
1994
  warn(`[context-trim] ctx.inject(llm) failed: ${error?.message ?? error}`);
1240
1995
  }
1241
1996
 
1242
- return { store, metrics, captures, inFlight, maybeFold, maybeInherit, applyViewToOptions, surfacePass };
1997
+ return { store, metrics, captures, inFlight, windowCache, advisoryNudgeBySession, maybeFold, maybeInherit, applyViewToOptions, surfacePass };
1243
1998
  }