dsh-switchman 1.10.3 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -53,7 +53,7 @@ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
53
53
  import { dirname, join } from "node:path";
54
54
  import { clearHandover, handoverOf, recordHandover } from "./handover-state.js";
55
55
  import { enumerateLiveSubagents, sessionRecordPath, sessionRecordsDir } from "./subagents.js";
56
- import { recentTrimViewTokensOf } from "./context-trim/index.js";
56
+ import { recentTrimPromptTokensOf } from "./context-trim/index.js";
57
57
 
58
58
  /** Tools whose result text is accounted against the per-turn read budget. */
59
59
  const ACCOUNT_TOOLS = new Set(["read", "glob", "grep", "bash"]);
@@ -115,11 +115,6 @@ function levelOf(used, th) {
115
115
  return "ok";
116
116
  }
117
117
 
118
- /** `42k`-style rounding for the anchor line. */
119
- function kilo(tokens) {
120
- return `${Math.round(tokens / 1000)}k`;
121
- }
122
-
123
118
  /** Session id off an exec/invocation agent (shared agent/session id). */
124
119
  function sessionIdOf(agent) {
125
120
  const id = agent?.session?.id ?? agent?.id;
@@ -141,7 +136,7 @@ function isRoot(ctx, agent) {
141
136
  function readThresholds(config) {
142
137
  return {
143
138
  soft: readNumber(config.wmSoftTokens, 80000),
144
- hard: readNumber(config.wmHardTokens, 120000),
139
+ hard: readNumber(config.wmHardTokens, 95000),
145
140
  force: readNumber(config.wmForceTokens, 150000),
146
141
  };
147
142
  }
@@ -151,15 +146,21 @@ export function measureNow(ctx, agent) {
151
146
  try {
152
147
  const measurement = agent?.session ? ctx.tokenMeter?.measure?.(agent.session) : undefined;
153
148
  const used = Number.isFinite(measurement?.totalTokens) ? measurement.totalTokens : null;
154
- // Meter duality (design §8): prefer the trim layer's fresh outbound-view
155
- // estimate when present — billion-context can fold far more away than the
156
- // meter's stored tally admits, and the watermark must track what the
157
- // model actually sees. Strictly additive + fail-open: any trim failure
158
- // falls back to the stored measure.
149
+ // Meter duality (design §8): prefer the trim layer's wire-domain meter —
150
+ // max(fresh outbound view, last sampled MAIN-dispatch prompt), the same
151
+ // number the trim pressure tiers evaluate — when present. Each pure
152
+ // source has one bias: the stored tally is wire-domain but never
153
+ // re-prices splice-level folds (it keeps counting material the model no
154
+ // longer receives — high bias), while the bare view never counts the
155
+ // system/tool sections (low bias). max(view, wire) fixes both and puts
156
+ // the watermark tiers (soft/hard/force + read budget) on the SAME
157
+ // boundary as the fold engine — wmHardTokens 95k ≙ trim pressure 90k
158
+ // (c453b91) holds in units, not just in numbers. Strictly additive +
159
+ // fail-open: any trim failure falls back to the stored measure.
159
160
  try {
160
161
  const sessionId = agent?.session?.id ?? (typeof agent?.id === "string" ? agent.id : null);
161
- const viewTokens = sessionId === null ? null : recentTrimViewTokensOf(sessionId);
162
- if (viewTokens !== null) return viewTokens;
162
+ const promptTokens = sessionId === null ? null : recentTrimPromptTokensOf(sessionId);
163
+ if (promptTokens !== null) return promptTokens;
163
164
  } catch {
164
165
  // fall through to the stored measure
165
166
  }
@@ -171,58 +172,53 @@ export function measureNow(ctx, agent) {
171
172
 
172
173
  // ---- watermark rendering (pure given state + measurement) ------------------
173
174
 
174
- /** Bucket width for the live used reading. 5k steps keep the tail snapshot
175
- * emission rate low (the snapshot re-emits only when its joined text
176
- * changes); tier crossings change the line immediately regardless. */
177
- const USED_BUCKET_TOKENS = 5000;
178
-
179
- /** `~50k`-style bucketed reading for the live watermark line. */
180
- function bucketKilo(tokens) {
181
- return `${Math.round(tokens / USED_BUCKET_TOKENS) * (USED_BUCKET_TOKENS / 1000)}k`;
182
- }
175
+ /** MODEL-VISIBILITY POLICY (docs/ladder-fold.md, bili lesson #2): the
176
+ * watermark NEVER shows the model a water level — no used-token counts, no
177
+ * budget values, no percentages, no cooldown minutes. A model told "you're
178
+ * at 20%" anchors on "looks healthy" and refuses to fold or wrap; with the
179
+ * numbers hidden, judgment roots in the task. Only BEHAVIORAL directives
180
+ * travel, and only when enforcement changes (a tier crossing or a handover
181
+ * phase change), which also keeps the runtime-context snapshot byte-stable
182
+ * between those events. The full numeric picture stays available to the
183
+ * human through the client badge. */
183
184
 
184
185
  /** Byte-stable protocol text for root sessions: no live numbers, so the
185
186
  * request head stays byte-identical for the whole session (KV-cache
186
- * invariant); the numbers live in the tail context line only. */
187
+ * invariant). */
187
188
  const PROTOCOL_ROOT_TEXT = [
188
- "[SWITCHMAN:WATERMARK] protocol: this session is guarded by a context watermark measured every step.",
189
- "ok tier is silent. soft tier: prefer delegating lightweight work (subagent/workflow) over reading more context yourself. hard tier: stop broad reading (read/glob/grep are budget-limited and may be denied) and start wrapping up. force tier: MANDATORY wrap-up now — produce the final answer from what you already hold; an automatic context handover (document + compaction + continuation) may run and will tell you to end the turn.",
190
- "The live tier and the bucketed used-token reading (against the soft/hard/force budget) arrive ONLY as [SWITCHMAN:WATERMARK] lines inside the appended runtime-context snapshot at the conversation tail; a tier change is announced on the step it happens. /ctx-pause pauses every watermark enforcement (measurement continues), /ctx-resume restores it, /ctx-handover starts a handover immediately.",
189
+ "[SWITCHMAN:WATERMARK] protocol: this session's context is managed automatically (folding runs before you notice; a handover may run when needed). You will never receive token counts, percentages, or progress numbers — none exist in this protocol, and no pacing decision is yours to make from them.",
190
+ "Only BEHAVIORAL directives arrive, as [SWITCHMAN:WATERMARK] lines inside the appended runtime-context snapshot, when enforcement changes: a soft line asks you to prefer delegation over reading more context yourself; a hard line says broad reading is budget-limited this turn and it is time to start wrapping up; a force line is a MANDATORY wrap-up — produce the final answer from what you already hold. /ctx-pause pauses every watermark enforcement (measurement continues), /ctx-resume restores it, /ctx-handover starts a handover immediately.",
191
191
  ].join("\n");
192
192
 
193
193
  /** Byte-stable protocol text for subagent sessions (same invariant). */
194
194
  const PROTOCOL_SUB_TEXT = [
195
- "[SWITCHMAN:WATERMARK] protocol: this subagent session is guarded by a context watermark measured every step.",
196
- "ok tier is silent. soft tier: keep reads narrow and drop bulky raw output. hard tier: stop broad reading; wrap toward the final summary. force tier: stop reading and return the final summary now — end the FINAL message with a HANDOFF block: completed / key findings (file:line) / remaining / next steps.",
197
- "The live tier and the bucketed used-token reading arrive ONLY as [SWITCHMAN:WATERMARK] lines inside the appended runtime-context snapshot; a tier change is announced on the step it happens.",
195
+ "[SWITCHMAN:WATERMARK] protocol: this subagent session's context is managed automatically. You will never receive token counts, percentages, or progress numbers — judgment stays rooted in the task.",
196
+ "Only BEHAVIORAL directives arrive, as [SWITCHMAN:WATERMARK] lines inside the appended runtime-context snapshot, when enforcement changes: a soft line asks for narrow reads; a hard line says stop broad reading and wrap toward the final summary; a force line says stop reading and return the final summary now, ending the FINAL message with a HANDOFF block: completed / key findings (file:line) / remaining / next steps.",
198
197
  ].join("\n");
199
198
 
200
- /** Static paused line (numbers omitted on purpose: enforcement is off, and a
199
+ /** Static paused line (never changes while paused: enforcement is off, and a
201
200
  * frozen string emits once and never churns the snapshot again). */
202
201
  const PAUSED_LINE =
203
202
  "[SWITCHMAN:WATERMARK] paused — measurement continues, but every watermark enforcement stays off until /ctx-resume.";
204
203
 
205
- /** Root-session live line: "" at ok (token economy), tier advice lines plus
206
- * the live handover progress (phase/attempt) and auto-cooldown state. */
207
- function renderRootWatermark(level, used, th, status) {
204
+ /** Root-session live line: directive text only — no numbers (see the
205
+ * policy note above). "" while the session sits at the silent ok tier. */
206
+ function renderRootWatermark(level, status) {
208
207
  if (level === "ok") return "";
209
- const head =
210
- `[SWITCHMAN:WATERMARK] ${level} tier — used ~${bucketKilo(used)}` +
211
- ` of budget ${kilo(th.soft)}/${kilo(th.hard)}/${kilo(th.force)}`;
212
- if (level === "soft") return `${head}\nsoft tier: prefer delegating lightweight work (subagent/workflow) over reading more context yourself.`;
213
- if (level === "hard") return `${head}\nhard tier: stop broad reading (read/glob/grep are budget-limited this turn) and start wrapping up.`;
214
- const lines = [head, "force tier: MANDATORY wrap-up now — produce the final answer from what you already hold."];
208
+ if (level === "soft") return "[SWITCHMAN:WATERMARK] soft tier: prefer delegating lightweight work (subagent/workflow) over reading more context yourself.";
209
+ if (level === "hard") return "[SWITCHMAN:WATERMARK] hard tier: stop broad reading (read/glob/grep are budget-limited this turn) and start wrapping up.";
210
+ const lines = ["[SWITCHMAN:WATERMARK] force tier: MANDATORY wrap-up now — produce the final answer from what you already hold."];
215
211
  const phase = status?.phase;
216
212
  if (phase === "backup") {
217
213
  lines.push(`context handover running: writing the handover document…`);
218
214
  } else if (phase === "compacting") {
219
- lines.push(`context handover running: compaction attempt ${status?.attempt ?? "?"} (retries while the turn is still running)…`);
215
+ lines.push(`context handover running: compaction in progress (retries while the turn is still running)…`);
220
216
  } else if (phase === "continuation") {
221
217
  lines.push(`context handover completing: waking this session to fill in the document and continue the task…`);
222
218
  } else if (status?.autoHandover) {
223
219
  lines.push(
224
- status?.cooldownMin > 0
225
- ? `automatic handover cooldown: ${status.cooldownMin} min left; run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to force one now.`
220
+ status?.cooldown
221
+ ? `automatic handover is cooling down; run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to force one now.`
226
222
  : `an automatic context handover (document + compaction + continuation) is armed and will fire at the next step boundary${status?.tool ? "; calling the ctx_handover tool triggers it immediately" : ""}.`,
227
223
  );
228
224
  } else {
@@ -231,21 +227,16 @@ function renderRootWatermark(level, used, th, status) {
231
227
  return lines.join("\n");
232
228
  }
233
229
 
234
- /** Subagent-session live line: the same soft/hard/force absolute-token tiers
235
- * as the root line ("" while ok — token economy), with subagent-flavored
236
- * advice and the HANDOFF block due at the force tier. Pausing never applies
237
- * to subagents (oc index.ts:187-190). */
238
- function renderSubWatermark(used, th) {
239
- const level = levelOf(used, th);
230
+ /** Subagent-session live line: the same tiers as the root line, directive
231
+ * text only — no numbers (see the policy note above). Pausing never
232
+ * applies to subagents (oc index.ts:187-190). */
233
+ function renderSubWatermark(level) {
240
234
  if (level === "ok") return "";
241
- const head =
242
- `[SWITCHMAN:WATERMARK] subagent ${level} tier — used ~${bucketKilo(used)}` +
243
- ` of budget ${kilo(th.soft)}/${kilo(th.hard)}/${kilo(th.force)}`;
244
- if (level === "soft") return `${head}\nsoft tier: keep reads narrow and drop bulky raw output.`;
245
- if (level === "hard") return `${head}\nhard tier: stop broad reading; start wrapping toward the final summary.`;
235
+ if (level === "soft") return "[SWITCHMAN:WATERMARK] soft tier: keep reads narrow and drop bulky raw output.";
236
+ if (level === "hard") return "[SWITCHMAN:WATERMARK] hard tier: stop broad reading; start wrapping toward the final summary.";
246
237
  return (
247
- `${head}\nforce tier: stop reading and return the final summary now.\n` +
248
- `End the FINAL message with a HANDOFF block: completed / key findings (file:line) / remaining / next steps.`
238
+ "[SWITCHMAN:WATERMARK] force tier: stop reading and return the final summary now.\n" +
239
+ "End the FINAL message with a HANDOFF block: completed / key findings (file:line) / remaining / next steps."
249
240
  );
250
241
  }
251
242
 
@@ -258,24 +249,24 @@ function renderWatermarkContext(ctx, config, state, agent) {
258
249
  const th = readThresholds(config);
259
250
  const used = measureNow(ctx, agent);
260
251
  if (used === null) return "";
252
+ const level = levelOf(used, th);
253
+ if (level === "ok") return "";
261
254
  if (!isRoot(ctx, agent)) {
262
255
  // Subagents share the root soft/hard thresholds; the optional
263
256
  // wmSubagentForceTokens override replaces only the force tier
264
257
  // (enabled by the wmSubagentCap toggle, 0/0 = follow the root set).
265
258
  const subForce = readBool(config.wmSubagentCap, true) ? readNumber(config.wmSubagentForceTokens, 0) : 0;
266
259
  const subTh = subForce > 0 ? { ...th, force: subForce } : th;
267
- return renderSubWatermark(used, subTh);
260
+ return renderSubWatermark(levelOf(used, subTh));
268
261
  }
269
262
  if (state.paused.has(sessionId)) return PAUSED_LINE;
270
263
  const live = handoverOf(sessionId);
271
264
  const elapsed = Date.now() - (state.lastHandover.get(sessionId) ?? 0);
272
- // Cooldown quantized to 5-minute steps so the emitted line stays
273
- // byte-stable between steps (snapshot re-emits only on change).
274
- return renderRootWatermark(levelOf(used, th), used, th, {
265
+ return renderRootWatermark(level, {
275
266
  autoHandover: readBool(config.wmAutoHandover, true),
276
267
  phase: live?.phase,
277
268
  attempt: live?.attempt,
278
- cooldownMin: Math.max(0, Math.ceil((HANDOVER_COOLDOWN_MS - elapsed) / 300_000) * 5),
269
+ cooldown: elapsed < HANDOVER_COOLDOWN_MS,
279
270
  tool: state.handoverTool ?? undefined,
280
271
  });
281
272
  } catch {
@@ -846,16 +837,26 @@ function applyContextWatch(ctx, config) {
846
837
  readString(config.wmDenyMode) !== "deny" &&
847
838
  Array.isArray(result?.content)
848
839
  ) {
849
- return {
850
- kind: "accept",
851
- content: [
852
- ...result.content,
853
- {
854
- type: "text",
855
- text: `[SWITCHMAN:WATERMARK] read budget exceeded this turn (used ${Math.round(entry.used)}/${Math.round(ceiling)} tokens) — further reads may be denied; wrap up or delegate the remaining reading.`,
856
- },
857
- ],
858
- };
840
+ // [2026-11-07]-[the warning branch used to return without
841
+ // calling next()]-[that short-circuited every post-execute
842
+ // handler downstream of the watermark (trim fork-link
843
+ // accounting, subagent model annotation) on exactly the
844
+ // turns that read the most. Run the pipeline first, then
845
+ // append the warning to whatever the pipeline accepted.]
846
+ return (async () => {
847
+ const downstream = await next();
848
+ const base = downstream !== null && typeof downstream === "object" && downstream.kind === "accept" && Array.isArray(downstream.content) ? downstream : result;
849
+ return {
850
+ kind: "accept",
851
+ content: [
852
+ ...base.content,
853
+ {
854
+ type: "text",
855
+ text: `[SWITCHMAN:WATERMARK] read budget exceeded this turn (used ${Math.round(entry.used)}/${Math.round(ceiling)} tokens) — further reads may be denied; wrap up or delegate the remaining reading.`,
856
+ },
857
+ ],
858
+ };
859
+ })();
859
860
  }
860
861
  }
861
862
  }
@@ -929,8 +930,15 @@ function applyContextWatch(ctx, config) {
929
930
  ctx.commands.register({
930
931
  name: "ctx-handover",
931
932
  description: "Trigger a full context handover for this session now (document + compaction + continuation).",
933
+ // Signal deliberately dropped (inspection W20): a turn-scoped
934
+ // AbortSignal shared by every /compact retry turns a user cancel into
935
+ // six straight failures walking the full ~530 s backoff ladder with a
936
+ // misleading error text — the same reason the ctx_handover TOOL path
937
+ // drops its turn signal. sessionCompact fabricates a fresh un-aborted
938
+ // signal when none arrives; user cancel lets compaction finish, like
939
+ // the tool path.
932
940
  handler: (invocation) =>
933
- runHandover(invocation?.agent, sessionIdOf(invocation?.agent), invocation?.commandId, invocation?.signal, "manual"),
941
+ runHandover(invocation?.agent, sessionIdOf(invocation?.agent), invocation?.commandId, undefined, "manual"),
934
942
  });
935
943
 
936
944
  // --- E. ctx_handover: the agent-callable front door to the same
@@ -989,7 +997,6 @@ function applyContextWatch(ctx, config) {
989
997
 
990
998
  export {
991
999
  applyContextWatch,
992
- bucketKilo,
993
1000
  PAUSED_LINE,
994
1001
  PROTOCOL_ROOT_TEXT,
995
1002
  PROTOCOL_SUB_TEXT,
package/host/routes.js CHANGED
@@ -95,6 +95,24 @@ const NUMBER_FIELDS = [
95
95
  "wmReadBudgetTokens",
96
96
  "wmSubagentForceTokens",
97
97
  ];
98
+ /** Trim numbers carry their own schema bounds (host/config.js), so the
99
+ * shared NUMBER_FIELDS coercion (which only rejects negatives) is too
100
+ * loose — these validate against each field's declared min/max. This group
101
+ * MUST mirror the client page's TRIM_NUMBER_FIELDS: a field coerced here
102
+ * but never sent by the client fails every fenced write (and vice versa). */
103
+ const TRIM_NUMBER_FIELDS = [
104
+ { field: "trimGrowthTokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 22500 },
105
+ { field: "trimIncrementTokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 50000 },
106
+ { field: "trimPressurePercent", min: 5, max: 100, def: 60 },
107
+ { field: "trimEmergencyPercent", min: 5, max: 100, def: 95 },
108
+ { field: "trimWindowTokens", min: 0, max: Number.POSITIVE_INFINITY, def: 0 },
109
+ { field: "trimProtectedTail", min: 0, max: 100, def: 6 },
110
+ { field: "trimMaxFoldsPerHour", min: 1, max: 60, def: 6 },
111
+ { field: "trimLadderT2Tokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 8000 },
112
+ { field: "trimLadderT2MinBlocks", min: 2, max: 200, def: 3 },
113
+ { field: "trimLadderT3Tokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 6000 },
114
+ { field: "trimLadderT3MinBlocks", min: 2, max: 200, def: 2 },
115
+ ];
98
116
  const BOOLEAN_FIELDS = [
99
117
  "wmAutoHandover",
100
118
  "wmSubagentCap",
@@ -110,6 +128,10 @@ const BOOLEAN_FIELDS = [
110
128
  "syncWhitelist",
111
129
  // Intent recognition (mirrors the intent* group in host/config.js).
112
130
  "intentEnabled",
131
+ // Context trim switches (mirrors the trim group in host/config.js).
132
+ "trimEnabled",
133
+ "trimLadderEnabled",
134
+ "surfaceFold",
113
135
  ];
114
136
  /** Intent numbers carry their own schema bounds (config.js), so the shared
115
137
  * NUMBER_FIELDS coercion (which only rejects negatives) is too loose — these
@@ -125,7 +147,7 @@ const INTENT_NUMBER_FIELDS = [
125
147
  /** Intent route references: unlike pool routes they round-trip the "unset"
126
148
  * {provider:"", model:""} pair, so they validate against the loosened
127
149
  * cleanIntentRoute (both keys must be strings; either may be empty). */
128
- const INTENT_ROUTE_FIELDS = ["intentClassifierRoute", "intentFallbackRoute"];
150
+ const INTENT_ROUTE_FIELDS = ["intentClassifierRoute", "intentFallbackRoute", "trimSideModel", "trimFoldFallbackModel"];
129
151
  /** Intent candidate list (route + optional effort/note per entry). */
130
152
  const INTENT_CANDIDATE_FIELDS = ["intentCandidates"];
131
153
 
@@ -144,7 +166,10 @@ const ENUM_FIELDS = {
144
166
  dispatchEnforce: ["off", "advice", "enforce"],
145
167
  wmDenyMode: ["cap", "deny"],
146
168
  intentClassifierKind: ["chat", "systemone"],
169
+ trimMode: ["off", "advisory", "auto"],
147
170
  };
171
+ /** Trim string fields (mirrors host/config.js strings). */
172
+ const TRIM_STRING_FIELDS = ["trimStateDir"];
148
173
 
149
174
  /** JSON response helper. */
150
175
  function writeJson(res, status, payload) {
@@ -312,6 +337,7 @@ function cleanIntentCandidate(entry) {
312
337
  export function snapshotOf(config) {
313
338
  const values = {};
314
339
  for (const field of STRING_FIELDS) values[field] = String(rawOf(config?.[field]) ?? "");
340
+ for (const field of TRIM_STRING_FIELDS) values[field] = String(rawOf(config?.[field]) ?? "");
315
341
  for (const field of POOL_FIELDS)
316
342
  values[field] = Array.isArray(rawOf(config?.[field]))
317
343
  ? rawOf(config[field]).map(cleanRoute).filter(Boolean)
@@ -337,6 +363,11 @@ export function snapshotOf(config) {
337
363
  values.dispatchEnforce = ENUM_FIELDS.dispatchEnforce.includes(rawOf(config?.dispatchEnforce))
338
364
  ? rawOf(config.dispatchEnforce)
339
365
  : "advice";
366
+ values.trimMode = ENUM_FIELDS.trimMode.includes(rawOf(config?.trimMode)) ? rawOf(config.trimMode) : "auto";
367
+ for (const { field, def } of TRIM_NUMBER_FIELDS) {
368
+ const raw = Number(rawOf(config?.[field]));
369
+ values[field] = Number.isFinite(raw) ? raw : def;
370
+ }
340
371
  for (const field of NUMBER_FIELDS) {
341
372
  const raw = Number(rawOf(config?.[field]));
342
373
  values[field] = Number.isFinite(raw) ? raw : 0;
@@ -407,6 +438,17 @@ export function coerceValues(input) {
407
438
  values.langScope = input.langScope;
408
439
  if (!ENUM_FIELDS.dispatchEnforce.includes(input.dispatchEnforce)) return null;
409
440
  values.dispatchEnforce = input.dispatchEnforce;
441
+ if (!ENUM_FIELDS.trimMode.includes(input.trimMode)) return null;
442
+ values.trimMode = input.trimMode;
443
+ for (const field of TRIM_STRING_FIELDS) {
444
+ if (typeof input[field] !== "string") return null;
445
+ values[field] = input[field];
446
+ }
447
+ for (const { field, min, max } of TRIM_NUMBER_FIELDS) {
448
+ const raw = Number(input[field]);
449
+ if (!Number.isFinite(raw) || raw < min || raw > max) return null;
450
+ values[field] = raw;
451
+ }
410
452
  for (const field of NUMBER_FIELDS) {
411
453
  const raw = Number(input[field]);
412
454
  if (!Number.isFinite(raw) || raw < 0) return null;
@@ -421,9 +463,27 @@ export function coerceValues(input) {
421
463
  return values;
422
464
  }
423
465
 
424
- /** Deep-equal on the plain-JSON value shapes this module exchanges. */
425
- function sameValues(a, b) {
426
- return JSON.stringify(a) === JSON.stringify(b);
466
+ /** Stable JSON text: object keys sorted at every depth. snapshotOf and
467
+ * coerceValues build the same fields in DIFFERENT insertion orders
468
+ * (e.g. trimStateDir sits 7th in a snapshot but near the tail after
469
+ * coercion), so a raw stringify compared key order, not content —
470
+ * every fenced save 409'd ("settings changed elsewhere") even with no
471
+ * intervening writer (2026-11-07 settings-save bug). */
472
+ function canonicalJson(value) {
473
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
474
+ if (value !== null && typeof value === "object")
475
+ return `{${Object.keys(value)
476
+ .sort()
477
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
478
+ .join(",")}}`;
479
+ return JSON.stringify(value) ?? "undefined";
480
+ }
481
+
482
+ /** Deep-equal on the plain-JSON value shapes this module exchanges;
483
+ * key-order insensitive (see canonicalJson). Exported for the smoke
484
+ * suite alongside coerceValues — the fence predicate IS the contract. */
485
+ export function sameValues(a, b) {
486
+ return canonicalJson(a) === canonicalJson(b);
427
487
  }
428
488
 
429
489
  /** Enumerate the model catalog from the host session controller. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-switchman",
3
- "version": "1.10.3",
3
+ "version": "1.11.0",
4
4
  "description": "Autonomous Agent Teams doctrine for the four shipped DeepSeek Harness presets, plus language preferences, dispatch pools with model ranking, and context watermark control.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -28,7 +28,7 @@
28
28
  "icon": "./icon.svg",
29
29
  "engines": {
30
30
  "node": ">=24",
31
- "dsh": ">=0.2.1-rc.1"
31
+ "dsh": ">=0.2.2-rc.1"
32
32
  },
33
33
  "exports": {
34
34
  ".": "./index.js",