dsh-switchman 1.11.6 → 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.
@@ -31,8 +31,8 @@
31
31
  * trimLadderT3Tokens, trimLadderT3MinBlocks.
32
32
  */
33
33
 
34
- import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
35
- import { join } from "node:path";
34
+ import { appendFileSync, mkdirSync, realpathSync, renameSync, statSync } from "node:fs";
35
+ import { join, resolve } from "node:path";
36
36
  import { buildView, estimateTokensOf, injectView, isFoldSummaryMessage, ledgerIsStale, messageTokens, rangeIsSegmentAligned, refOf, refPositions } from "./view.js";
37
37
  import { computeFoldableRange, evaluateTrigger, EMERGENCY_COOLDOWN_MS } from "./trigger.js";
38
38
  import { LADDER_SYSTEM_PROMPT, mechanicalSummary, renderLadderMaterial, renderMaterial, summarizeWithModel } from "./summarizer.js";
@@ -41,6 +41,46 @@ import { createMetrics, extractUsage, promptTokensOf, wrapStreamForMetrics } fro
41
41
  import { forkLinkOf, loadForkLinks, recordForkLink, resolveInheritance, INHERIT_WINDOW_MS } from "./fork.js";
42
42
  import { createSurfaceFoldPass } from "./surface.js";
43
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
+
44
84
 
45
85
  // ---- agent reference helpers --------------------------------------------------
46
86
 
@@ -59,6 +99,7 @@ import { childSessionIdOf } from "../subagent-model.js";
59
99
  // it and the stored tally takes over.
60
100
  const trimViewBySession = new Map(); // sessionId -> { viewTokens, at, inject, lastNoneAt }
61
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.
62
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
63
104
  /** Freshness TTL for the in-memory view/injection registry (watermark and
64
105
  * banner display only). NEVER a fold gate: gating folds on "did the surface
@@ -178,6 +219,45 @@ function noteTrimWireTokens(sessionId, usage) {
178
219
  }
179
220
  }
180
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
+
181
261
  /** Injection outcome of the last dispatch for one session ("in-place" |
182
262
  * "swapped" | "none"), or null when unknown or stale. Read by maybeFold:
183
263
  * when the wire cannot carry the view, folding cannot relieve anything and
@@ -272,21 +352,33 @@ export const TRIM_PROTOCOL_TEXT = [
272
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.",
273
353
  "- NEVER contradict or re-derive content a summary already settled; build on it.",
274
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.",
275
356
  "- Tool-call pairs (an assistant tool call and its results) must always be folded together or not at all.",
276
357
  ].join("\n");
277
358
 
278
- // Advisory nudge thresholds: the default speak line (65%) deliberately sits
279
- // below the protocol's own 80% fold line so the model is nudged while it
280
- // still has comfortable headroom; critical wording escalates at 95%. At most
281
- // one nudge per session per 10 minutes (an instant re-nudge when the level
282
- // band changes). Both lines are config-tunable via trimAdvisoryPercent /
283
- // trimAdvisoryCriticalPercent.
284
- export const ADVISORY_NUDGE_PERCENT = 65;
285
- export const ADVISORY_NUDGE_CRITICAL_PERCENT = 95;
286
- export const ADVISORY_NUDGE_REPEAT_MS = 600_000;
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;
287
379
 
288
380
  /** Effective advisory lines for one dispatch: config-tunable, falling back to
289
- * the configured defaults (65/95) when absent, inverted, or out of range —
381
+ * the configured defaults (50/80) when absent, inverted, or out of range —
290
382
  * a broken pair never silences the nudge. */
291
383
  function advisoryNudgeLines(config) {
292
384
  const line = readNumber(config, "trimAdvisoryPercent", ADVISORY_NUDGE_PERCENT);
@@ -296,20 +388,42 @@ function advisoryNudgeLines(config) {
296
388
  return { line, critical };
297
389
  }
298
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
+
299
402
  /** Advisory-mode nudge text. Deliberate, bounded exception to the
300
- * digit-free disclosure policy of TRIM_PROTOCOL_TEXT above: advisory mode has
301
- * NO engine-initiated folds (the modeNow gate), so the model's own fold is
302
- * the only relief channel. At threshold, the live-number instruction is
303
- * appended to the session surface tail, not injected into the registered
304
- * system section. */
305
- export function advisoryNudgeText(pressurePercent, critical, foldLine = ADVISORY_NUDGE_PERCENT, criticalLine = ADVISORY_NUDGE_CRITICAL_PERCENT) {
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) {
306
410
  const action = critical
307
411
  ? `Pressure is CRITICAL (${pressurePercent}% ≥ ${criticalLine}%): fold down to under ~50% of the context IN THIS TURN.`
308
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.";
309
422
  return [
310
423
  '[SWITCHMAN:TRIM] Advisory nudge — pinned to the session tail until pressure falls below the fold line.',
311
424
  `Live context pressure on this session has reached the fold line. ${action}`,
312
- "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). Do not wait for a routine moment.",
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
313
427
  ].join("\n");
314
428
  }
315
429
 
@@ -362,7 +476,7 @@ export function applyContextTrim(ctx, config) {
362
476
 
363
477
  // Per-process pipeline state (process-wide by design: ledgers persist on
364
478
  // disk per session, metrics/captures live per session in memory).
365
- const store = createStore({ stateDir: readString(config, "trimStateDir", null) ?? undefined, warn });
479
+ const store = createStore({ stateDir: resolveStateDir(readString(config, "trimStateDir", null), warn) ?? undefined, warn });
366
480
 
367
481
  // Surface-replace pass (docs/surface-fold.md): ships committed fold blocks
368
482
  // onto the session surface so folds reach the wire even for frozen
@@ -417,6 +531,32 @@ export function applyContextTrim(ctx, config) {
417
531
  });
418
532
  }
419
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
+
420
560
  /** Advisory-mode pressure nudge (text: advisoryNudgeText). Appends the live
421
561
  * instruction to the session surface tail once pressure crosses the fold
422
562
  * line. Pressure reuses the meter-duality bridge (wire preferred, view
@@ -429,7 +569,10 @@ export function applyContextTrim(ctx, config) {
429
569
  * cache reuse survives up to the nudge instead of invalidating the whole
430
570
  * prefix as the old system-head splice did. */
431
571
  function deliverAdvisoryNudge(sessionId, viewTokens) {
432
- if (modeNow() !== "advisory") return;
572
+ if (modeNow() !== "advisory") {
573
+ debug(`nudge skip mode=${modeNow()} session=${sessionId}`);
574
+ return;
575
+ }
433
576
  const model = capturedModelBySession.get(sessionId) ?? null;
434
577
  if (model === null) {
435
578
  decisionLog("advisory-nudge skip why=no-captured-model");
@@ -468,7 +611,10 @@ export function applyContextTrim(ctx, config) {
468
611
  }
469
612
  const wire = wireTokensOf(sessionId);
470
613
  const mass = Math.max(Number.isFinite(wire) ? wire : 0, Number.isFinite(viewTokens) ? viewTokens : 0);
471
- if (mass <= 0) return;
614
+ if (mass <= 0) {
615
+ debug(`nudge skip empty-mass session=${sessionId}`);
616
+ return;
617
+ }
472
618
  const pressurePercent = (mass / windowTokens) * 100;
473
619
  const nudgeLines = advisoryNudgeLines(config);
474
620
  const prior = advisoryNudgeBySession.get(sessionId);
@@ -486,17 +632,129 @@ export function applyContextTrim(ctx, config) {
486
632
  return;
487
633
  }
488
634
  advisoryNudgeBySession.delete(sessionId);
489
- decisionLog("advisory-nudge below-line pressure=" + Math.round(pressurePercent) + "% mass=" + Math.round(mass) + " resolved=" + cached.windowTokens + " cap=" + windowCapNow() + " pruned=" + seqs.size);
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);
490
640
  } else if (prior !== undefined) {
491
641
  advisoryNudgeBySession.delete(sessionId); // nothing visible left; close the episode quietly
492
642
  }
493
643
  return; // fresh episode on the next crossing
494
644
  }
495
- const critical = pressurePercent >= nudgeLines.critical;
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);
496
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
+ }
497
741
  // Any band CHANGE re-speaks immediately: escalation high->critical, and
498
742
  // intentionally also a downgrade back into high after a partial fold.
499
- if (prior !== undefined && prior.critical === critical && now - prior.at < ADVISORY_NUDGE_REPEAT_MS) return;
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
+ }
500
758
  if (!surfacePass.nudgeSupported(sessionId)) {
501
759
  decisionLog("advisory-nudge skip why=surface-unavailable pressure=" + Math.round(pressurePercent) + "%");
502
760
  return;
@@ -521,7 +779,15 @@ export function applyContextTrim(ctx, config) {
521
779
  decisionLog("advisory-nudge orphan-pruned seqs=" + orphans.length + " pressure=" + Math.round(pressurePercent) + "%");
522
780
  }
523
781
  }
524
- const nudge = advisoryNudgeText(Math.round(pressurePercent), critical, nudgeLines.line, nudgeLines.critical);
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
+ );
525
791
  const appended = surfacePass.nudgeAppend(sessionId, nudge);
526
792
  if (appended.error !== null || !Number.isFinite(appended.seq)) {
527
793
  advisoryNudgeBySession.delete(sessionId); // a pruned prior must not throttle the retry
@@ -530,9 +796,10 @@ export function applyContextTrim(ctx, config) {
530
796
  }
531
797
  const seq = appended.seq;
532
798
  advisoryNudgeBySession.delete(sessionId); // re-insert: refresh recency so eviction drops the least recently nudged
533
- advisoryNudgeBySession.set(sessionId, { at: now, critical, seq });
799
+ advisoryNudgeBySession.set(sessionId, { at: now, band, seq, repeats: nextRepeats });
800
+ debug(`nudge prepared seq=${seq} level=${band} pressure=${Math.round(pressurePercent)}% session=${sessionId}`);
534
801
  if (advisoryNudgeBySession.size > 200) advisoryNudgeBySession.delete(advisoryNudgeBySession.keys().next().value);
535
- decisionLog("session=" + sessionId + " advisory-nudge pressure=" + Math.round(pressurePercent) + "% mass=" + Math.round(mass) + " window=" + windowTokens + " level=" + (critical ? "critical" : "high") + " seq=" + seq);
802
+ decisionLog("session=" + sessionId + " advisory-nudge pressure=" + Math.round(pressurePercent) + "% mass=" + Math.round(mass) + " window=" + windowTokens + " level=" + band + " repeats=" + nextRepeats + " seq=" + seq);
536
803
  }
537
804
 
538
805
  const captures = new Map(); // sessionId -> { messages, refs, viewTokens, model, at }
@@ -552,6 +819,12 @@ export function applyContextTrim(ctx, config) {
552
819
  const WINDOW_RESOLVE_TIMEOUT_MS = 10_000;
553
820
  const windowCache = new Map(); // "provider/model" -> { windowTokens, at }
554
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)
555
828
  const lastStreamCheckAt = new Map(); // sessionId -> epoch ms of last stream-path fold check (throttle)
556
829
  const epochResetDoneBySession = new Map(); // sessionId -> ledger-stale baseline reset already done for this host epoch (cleared when a fold applies again)
557
830
 
@@ -601,6 +874,34 @@ export function applyContextTrim(ctx, config) {
601
874
  }
602
875
  }
603
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
+
604
905
  // ---- fork-state inheritance (copy-on-fork; design in fork.js header) ------
605
906
  const linksCache = { mtime: -1, map: null }; // mtime-gated fork-links map
606
907
  const inheritFirstSeen = new Map(); // sessionId -> epoch ms of first eligibility check
@@ -683,17 +984,22 @@ export function applyContextTrim(ctx, config) {
683
984
  const mode = readString(config, "trimMode", "auto");
684
985
  return mode === "off" || mode === "advisory" || mode === "auto" ? mode : "auto";
685
986
  };
987
+ const reliefEnabledNow = () => readBool(config, "trimCriticalRelief", true);
686
988
  const thresholdsNow = () => ({
687
989
  growthTokens: readNumber(config, "trimGrowthTokens", 22_500),
688
- incrementTokens: readNumber(config, "trimIncrementTokens", 25_000),
689
- pressurePercent: readNumber(config, "trimPressurePercent", 65),
690
- emergencyPercent: readNumber(config, "trimEmergencyPercent", 95),
691
- protectedTail: Math.max(0, Math.round(readNumber(config, "trimProtectedTail", 10))),
692
- 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))),
693
999
  ladderT2Tokens: readNumber(config, "trimLadderT2Tokens", 8_000),
694
- ladderT2MinBlocks: readNumber(config, "trimLadderT2MinBlocks", 3),
1000
+ ladderT2MinBlocks: Math.max(2, Math.round(readNumber(config, "trimLadderT2MinBlocks", 3))),
695
1001
  ladderT3Tokens: readNumber(config, "trimLadderT3Tokens", 6_000),
696
- ladderT3MinBlocks: readNumber(config, "trimLadderT3MinBlocks", 2),
1002
+ ladderT3MinBlocks: Math.max(2, Math.round(readNumber(config, "trimLadderT3MinBlocks", 2))),
697
1003
  protectTaskIntent: readBool(config, "trimProtectTaskIntent", true),
698
1004
  });
699
1005
  const ladderEnabledNow = () => readBool(config, "trimLadderEnabled", true);
@@ -736,10 +1042,69 @@ export function applyContextTrim(ctx, config) {
736
1042
  const windowCapNow = () => {
737
1043
  const explicit = readNumber(config, "trimWindowTokens", 0);
738
1044
  if (explicit > 0) return explicit;
739
- const force = readNumber(config, "wmForceTokens", 150_000);
1045
+ const force = readNumber(config, "wmForceTokens", 200_000);
740
1046
  return force > 0 ? force : null;
741
1047
  };
742
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
+
743
1108
  /** Session id for a stream call: options.sessionId, else the current
744
1109
  * initiator's session (try/catch — host internals shift). */
745
1110
  function sessionIdOf(options) {
@@ -848,10 +1213,16 @@ export function applyContextTrim(ctx, config) {
848
1213
  // previous response's usage) makes the view-vs-wire divergence visible
849
1214
  // instead of leaving the view estimate as the only lullaby in the log.
850
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;
851
1222
  // Per-request application evidence: whether the ledger's blocks actually
852
1223
  // locate inside THIS re-send (the host may compact/replace history, in
853
1224
  // which case applied=0 explains a stuck-high view without any failure).
854
- 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}` : ""}`);
855
1226
  // Side-dispatch guard: auxiliary llm calls that inherit the session id
856
1227
  // (web-search summaries, title generation, fold-span material) present
857
1228
  // tiny arrays in which the ledger cannot locate. Accepting them as the
@@ -958,7 +1329,10 @@ export function applyContextTrim(ctx, config) {
958
1329
  * Returns a short outcome string for logging. */
959
1330
  async function runFoldCycle(sessionId, decision, cycleStartedAt, cycleToken) {
960
1331
  const capture = captures.get(sessionId);
961
- if (capture === undefined) return "no-capture";
1332
+ if (capture === undefined) {
1333
+ debug(`cycle step no-capture session=${sessionId}`);
1334
+ return "no-capture";
1335
+ }
962
1336
  const { protectedTail } = thresholdsNow();
963
1337
  // Ladder cycle (docs/ladder-fold.md): distill the decision's consumed
964
1338
  // run of block summaries into ONE replacement. Never mechanical — a
@@ -1003,6 +1377,7 @@ export function applyContextTrim(ctx, config) {
1003
1377
  const refs = slice.map((message, i) => refOf(message, startIdx + i));
1004
1378
  const count = slice.length;
1005
1379
  const approxTokens = estimateTokensOf(slice);
1380
+ debug(`cycle step begin kind=${decision.kind} tier=${decision.tier ?? "-"} range=${startIdx}..${endIdx} session=${sessionId}`);
1006
1381
  let summary = null;
1007
1382
  let kind = "summary";
1008
1383
  let degraded = false; // set when a PRESSURE fold falls back to mechanical
@@ -1025,6 +1400,7 @@ export function applyContextTrim(ctx, config) {
1025
1400
  onFail: (why) => decisionLog(`session=${sessionId} summary-fail route=${tag} why=${why} msgs=${count} ~${approxTokens}t`),
1026
1401
  onNotice: (why) => decisionLog(`session=${sessionId} summary-retry route=${tag} why=${why} msgs=${count} ~${approxTokens}t`),
1027
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}`);
1028
1404
  summary = route !== null ? await summarize(route, "side") : null;
1029
1405
  if (summary === null && fallback !== null && !sameRoute(fallback, route)) summary = await summarize(fallback, "fallback");
1030
1406
  // Last resort for PRESSURE folds only: relief beats fidelity when
@@ -1042,6 +1418,7 @@ export function applyContextTrim(ctx, config) {
1042
1418
  }
1043
1419
  const positions = refPositions(capture.messages);
1044
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}`);
1045
1422
  const result = store.commitFold(
1046
1423
  sessionId,
1047
1424
  { refRange: { firstId: refs[0], lastId: refs[refs.length - 1] }, count, approxTokens, summary, kind, tier: decision.tier },
@@ -1066,15 +1443,28 @@ export function applyContextTrim(ctx, config) {
1066
1443
  async function maybeFold(agent, via = "idle") {
1067
1444
  const sessionId = agent;
1068
1445
  if (sessionId === null || sessionId === undefined || sessionId === "") return;
1069
- if (!enabledNow() || modeNow() !== "auto") return;
1070
- 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
+ }
1071
1454
  // Failure backoff: a session whose recent cycles failed stays quiet
1072
1455
  // for 60s*2^(n-1) (cap 15 min) instead of re-firing at every idle
1073
1456
  // boundary and stream check. Silent gate — the outcome row that armed
1074
1457
  // the cooldown is already in decisions.log.
1075
- 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
+ }
1076
1463
  const capture = captures.get(sessionId);
1077
- if (capture === undefined) return;
1464
+ if (capture === undefined) {
1465
+ debug(`session=${sessionId} via=${via} skip no-capture`);
1466
+ return;
1467
+ }
1078
1468
  // Injection-unavailable: the last dispatch could not carry the view
1079
1469
  // (frozen options AND messages — the wire always ships raw history).
1080
1470
  // Folding is pointless ONLY when NEITHER channel can reach the wire:
@@ -1153,6 +1543,7 @@ export function applyContextTrim(ctx, config) {
1153
1543
  // that is a reload cut, not a model failure: log it and skip the ladder.
1154
1544
  if (disposedSince(cycleStartedAt)) decisionLog(`session=${sessionId} outcome=reload-aborted tier=${decision?.tier ?? "-"}`);
1155
1545
  else {
1546
+ errorLog("fold-cycle", error);
1156
1547
  recordFoldFailure(sessionId); // a thrown cycle counts as failed too
1157
1548
  warn(`[context-trim] fold cycle failed: ${error?.message ?? error}`);
1158
1549
  }
@@ -1186,6 +1577,9 @@ export function applyContextTrim(ctx, config) {
1186
1577
  // agent }) — derive the session id per host/context-watch.js:122.
1187
1578
  const sessionId = agentSessionIdOf(payload?.agent);
1188
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);
1189
1583
  // Fold first, then the surface-replace idle sweep (design
1190
1584
  // docs/surface-fold.md): a block committed by this idle pass
1191
1585
  // ships on the same boundary.
@@ -1195,7 +1589,12 @@ export function applyContextTrim(ctx, config) {
1195
1589
  } catch {
1196
1590
  /* sweep must never break the idle hook */
1197
1591
  }
1198
- });
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
+ });
1199
1598
  } catch {
1200
1599
  /* idle hook must never throw */
1201
1600
  }
@@ -1340,8 +1739,26 @@ export function applyContextTrim(ctx, config) {
1340
1739
  const capture = captures.get(sessionId);
1341
1740
  if (capture === undefined) return "No captured history yet — fold is unavailable until the next model call.";
1342
1741
  const positions = refPositions(capture.messages);
1343
- const firstList = positions.get(String(args?.firstId ?? ""));
1344
- 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
+
1345
1762
  if (firstList === undefined || lastList === undefined) return "Refs not found in the current history — re-check with ctx_trim_status.";
1346
1763
  // Count-anchored resolution: duplicate refs (identical content)
1347
1764
  // make the first/last occurrence lists ambiguous — anchor on the
@@ -1372,7 +1789,12 @@ export function applyContextTrim(ctx, config) {
1372
1789
  const result = store.commitFold(
1373
1790
  sessionId,
1374
1791
  {
1375
- 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
+ },
1376
1798
  count,
1377
1799
  approxTokens: estimateTokensOf(capture.messages, firstIdx, lastIdx),
1378
1800
  summary: String(args.summary ?? ""),
@@ -1382,6 +1804,11 @@ export function applyContextTrim(ctx, config) {
1382
1804
  positions,
1383
1805
  );
1384
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
+
1385
1812
  surfacePass.captureFoldSeqs(sessionId, result.block);
1386
1813
  // Growth-gate baseline pair: pre-fold view (banner display) plus the
1387
1814
  // post-fold low (tier-a anchor — keeps the gate armed after folds
@@ -1420,12 +1847,14 @@ export function applyContextTrim(ctx, config) {
1420
1847
  mainDispatch = applied.sideDispatch !== true;
1421
1848
  if (mainDispatch) {
1422
1849
  // Advisory pressure nudge (modeNow gates inside): the model's
1423
- // own fold is advisory mode's ONLY relief channel — speak at
1424
- // the fold line. Fail-open, never breaks the dispatch.
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.
1425
1853
  try {
1426
1854
  deliverAdvisoryNudge(applied.sessionId, applied.viewTokens);
1427
1855
  } catch (error) {
1428
1856
  decisionLog(`advisory-nudge-failed why=${error?.message ?? error}`);
1857
+ errorLog("advisory-nudge", error);
1429
1858
  }
1430
1859
  }
1431
1860
  }
@@ -1433,6 +1862,7 @@ export function applyContextTrim(ctx, config) {
1433
1862
  } catch (error) {
1434
1863
  decisionLog(`view-build-failed why=${error?.message ?? error}`);
1435
1864
  warn(`[context-trim] view build failed: ${error?.message ?? error}`);
1865
+ errorLog("view-build", error);
1436
1866
  }
1437
1867
  const result = next();
1438
1868
  let wrappedSession = null;
@@ -1445,10 +1875,24 @@ export function applyContextTrim(ctx, config) {
1445
1875
  if (usage !== null) {
1446
1876
  metrics.recordUsage(wrappedSession, usage);
1447
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
+ }
1448
1890
  }
1449
1891
  });
1450
1892
  } catch (error) {
1451
1893
  warn(`[context-trim] stream wrap failed: ${error?.message ?? error}`);
1894
+ decisionLog(`stream-wrap-failed why=${error?.message ?? error}`);
1895
+ errorLog("stream-wrap", error);
1452
1896
  return result;
1453
1897
  } finally {
1454
1898
  // Fallback fold check on the stream path: the agent/status idle event
@@ -1466,17 +1910,32 @@ export function applyContextTrim(ctx, config) {
1466
1910
  if (nowMs - (lastStreamCheckAt.get(wrappedSession) ?? 0) >= 60_000) {
1467
1911
  lastStreamCheckAt.set(wrappedSession, nowMs);
1468
1912
  if (lastStreamCheckAt.size > 200) lastStreamCheckAt.delete(lastStreamCheckAt.keys().next().value);
1469
- 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
+ });
1470
1927
  }
1471
1928
  }
1472
- } catch {
1929
+ } catch (error) {
1473
1930
  /* fallback must never break the stream */
1931
+ errorLog("stream-fallback", error);
1474
1932
  }
1475
1933
  }
1476
1934
  }
1477
1935
 
1478
1936
  try {
1479
1937
  ctx.inject(["llm"], (llmCtx) => {
1938
+ const disposeListenerOf = new Map(); // "llm/stream" -> the on() disposer of the current attach
1480
1939
  const attach = () => {
1481
1940
  try {
1482
1941
  // Global + prepend: the cordis dispatch filter silently drops NON-global
@@ -1485,10 +1944,23 @@ export function applyContextTrim(ctx, config) {
1485
1944
  // subtree) — that silenced the whole stream path for a resumed session
1486
1945
  // while fresh sessions kept logging. Stock consumers (dsh-session-title)
1487
1946
  // register exactly this way.
1488
- llmCtx.on?.("llm/stream", streamListener, { global: true, prepend: true });
1489
- 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}`);
1490
1960
  } catch (error) {
1491
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);
1492
1964
  }
1493
1965
  };
1494
1966
  // Reload-safe release (applyIntent precedent): undo the listener
@@ -1497,14 +1969,21 @@ export function applyContextTrim(ctx, config) {
1497
1969
  const effect = llmCtx.effect;
1498
1970
  if (typeof effect === "function") {
1499
1971
  effect(function* () {
1500
- yield () => {
1501
- try {
1502
- llmCtx.off?.("llm/stream", streamListener);
1503
- } catch {
1504
- /* best effort */
1505
- }
1506
- };
1507
- }, "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");
1508
1987
  }
1509
1988
  } catch {
1510
1989
  /* effect registration is best-effort */