dsh-switchman 1.10.3 → 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,22 @@ 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
158
+ // 97.5k (65% of the 150k window) — holds in units, not just in
159
+ // numbers. Strictly additive +
160
+ // fail-open: any trim failure falls back to the stored measure.
159
161
  try {
160
162
  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;
163
+ const promptTokens = sessionId === null ? null : recentTrimPromptTokensOf(sessionId);
164
+ if (promptTokens !== null) return promptTokens;
163
165
  } catch {
164
166
  // fall through to the stored measure
165
167
  }
@@ -171,58 +173,53 @@ export function measureNow(ctx, agent) {
171
173
 
172
174
  // ---- watermark rendering (pure given state + measurement) ------------------
173
175
 
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
- }
176
+ /** MODEL-VISIBILITY POLICY (docs/ladder-fold.md, bili lesson #2): the
177
+ * watermark NEVER shows the model a water level — no used-token counts, no
178
+ * budget values, no percentages, no cooldown minutes. A model told "you're
179
+ * at 20%" anchors on "looks healthy" and refuses to fold or wrap; with the
180
+ * numbers hidden, judgment roots in the task. Only BEHAVIORAL directives
181
+ * travel, and only when enforcement changes (a tier crossing or a handover
182
+ * phase change), which also keeps the runtime-context snapshot byte-stable
183
+ * between those events. The full numeric picture stays available to the
184
+ * human through the client badge. */
183
185
 
184
186
  /** Byte-stable protocol text for root sessions: no live numbers, so the
185
187
  * request head stays byte-identical for the whole session (KV-cache
186
- * invariant); the numbers live in the tail context line only. */
188
+ * invariant). */
187
189
  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.",
190
+ "[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.",
191
+ "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
192
  ].join("\n");
192
193
 
193
194
  /** Byte-stable protocol text for subagent sessions (same invariant). */
194
195
  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.",
196
+ "[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.",
197
+ "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
198
  ].join("\n");
199
199
 
200
- /** Static paused line (numbers omitted on purpose: enforcement is off, and a
200
+ /** Static paused line (never changes while paused: enforcement is off, and a
201
201
  * frozen string emits once and never churns the snapshot again). */
202
202
  const PAUSED_LINE =
203
203
  "[SWITCHMAN:WATERMARK] paused — measurement continues, but every watermark enforcement stays off until /ctx-resume.";
204
204
 
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) {
205
+ /** Root-session live line: directive text only — no numbers (see the
206
+ * policy note above). "" while the session sits at the silent ok tier. */
207
+ function renderRootWatermark(level, status) {
208
208
  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."];
209
+ if (level === "soft") return "[SWITCHMAN:WATERMARK] soft tier: prefer delegating lightweight work (subagent/workflow) over reading more context yourself.";
210
+ if (level === "hard") return "[SWITCHMAN:WATERMARK] hard tier: stop broad reading (read/glob/grep are budget-limited this turn) and start wrapping up.";
211
+ const lines = ["[SWITCHMAN:WATERMARK] force tier: MANDATORY wrap-up now — produce the final answer from what you already hold."];
215
212
  const phase = status?.phase;
216
213
  if (phase === "backup") {
217
214
  lines.push(`context handover running: writing the handover document…`);
218
215
  } else if (phase === "compacting") {
219
- lines.push(`context handover running: compaction attempt ${status?.attempt ?? "?"} (retries while the turn is still running)…`);
216
+ lines.push(`context handover running: compaction in progress (retries while the turn is still running)…`);
220
217
  } else if (phase === "continuation") {
221
218
  lines.push(`context handover completing: waking this session to fill in the document and continue the task…`);
222
219
  } else if (status?.autoHandover) {
223
220
  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.`
221
+ status?.cooldown
222
+ ? `automatic handover is cooling down; run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to force one now.`
226
223
  : `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
224
  );
228
225
  } else {
@@ -231,21 +228,16 @@ function renderRootWatermark(level, used, th, status) {
231
228
  return lines.join("\n");
232
229
  }
233
230
 
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);
231
+ /** Subagent-session live line: the same tiers as the root line, directive
232
+ * text only — no numbers (see the policy note above). Pausing never
233
+ * applies to subagents (oc index.ts:187-190). */
234
+ function renderSubWatermark(level) {
240
235
  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.`;
236
+ if (level === "soft") return "[SWITCHMAN:WATERMARK] soft tier: keep reads narrow and drop bulky raw output.";
237
+ if (level === "hard") return "[SWITCHMAN:WATERMARK] hard tier: stop broad reading; start wrapping toward the final summary.";
246
238
  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.`
239
+ "[SWITCHMAN:WATERMARK] force tier: stop reading and return the final summary now.\n" +
240
+ "End the FINAL message with a HANDOFF block: completed / key findings (file:line) / remaining / next steps."
249
241
  );
250
242
  }
251
243
 
@@ -258,24 +250,24 @@ function renderWatermarkContext(ctx, config, state, agent) {
258
250
  const th = readThresholds(config);
259
251
  const used = measureNow(ctx, agent);
260
252
  if (used === null) return "";
253
+ const level = levelOf(used, th);
254
+ if (level === "ok") return "";
261
255
  if (!isRoot(ctx, agent)) {
262
256
  // Subagents share the root soft/hard thresholds; the optional
263
257
  // wmSubagentForceTokens override replaces only the force tier
264
258
  // (enabled by the wmSubagentCap toggle, 0/0 = follow the root set).
265
259
  const subForce = readBool(config.wmSubagentCap, true) ? readNumber(config.wmSubagentForceTokens, 0) : 0;
266
260
  const subTh = subForce > 0 ? { ...th, force: subForce } : th;
267
- return renderSubWatermark(used, subTh);
261
+ return renderSubWatermark(levelOf(used, subTh));
268
262
  }
269
263
  if (state.paused.has(sessionId)) return PAUSED_LINE;
270
264
  const live = handoverOf(sessionId);
271
265
  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, {
266
+ return renderRootWatermark(level, {
275
267
  autoHandover: readBool(config.wmAutoHandover, true),
276
268
  phase: live?.phase,
277
269
  attempt: live?.attempt,
278
- cooldownMin: Math.max(0, Math.ceil((HANDOVER_COOLDOWN_MS - elapsed) / 300_000) * 5),
270
+ cooldown: elapsed < HANDOVER_COOLDOWN_MS,
279
271
  tool: state.handoverTool ?? undefined,
280
272
  });
281
273
  } catch {
@@ -846,16 +838,26 @@ function applyContextWatch(ctx, config) {
846
838
  readString(config.wmDenyMode) !== "deny" &&
847
839
  Array.isArray(result?.content)
848
840
  ) {
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
- };
841
+ // [2026-11-07]-[the warning branch used to return without
842
+ // calling next()]-[that short-circuited every post-execute
843
+ // handler downstream of the watermark (trim fork-link
844
+ // accounting, subagent model annotation) on exactly the
845
+ // turns that read the most. Run the pipeline first, then
846
+ // append the warning to whatever the pipeline accepted.]
847
+ return (async () => {
848
+ const downstream = await next();
849
+ const base = downstream !== null && typeof downstream === "object" && downstream.kind === "accept" && Array.isArray(downstream.content) ? downstream : result;
850
+ return {
851
+ kind: "accept",
852
+ content: [
853
+ ...base.content,
854
+ {
855
+ type: "text",
856
+ 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.`,
857
+ },
858
+ ],
859
+ };
860
+ })();
859
861
  }
860
862
  }
861
863
  }
@@ -929,8 +931,15 @@ function applyContextWatch(ctx, config) {
929
931
  ctx.commands.register({
930
932
  name: "ctx-handover",
931
933
  description: "Trigger a full context handover for this session now (document + compaction + continuation).",
934
+ // Signal deliberately dropped (inspection W20): a turn-scoped
935
+ // AbortSignal shared by every /compact retry turns a user cancel into
936
+ // six straight failures walking the full ~530 s backoff ladder with a
937
+ // misleading error text — the same reason the ctx_handover TOOL path
938
+ // drops its turn signal. sessionCompact fabricates a fresh un-aborted
939
+ // signal when none arrives; user cancel lets compaction finish, like
940
+ // the tool path.
932
941
  handler: (invocation) =>
933
- runHandover(invocation?.agent, sessionIdOf(invocation?.agent), invocation?.commandId, invocation?.signal, "manual"),
942
+ runHandover(invocation?.agent, sessionIdOf(invocation?.agent), invocation?.commandId, undefined, "manual"),
934
943
  });
935
944
 
936
945
  // --- E. ctx_handover: the agent-callable front door to the same
@@ -989,7 +998,6 @@ function applyContextWatch(ctx, config) {
989
998
 
990
999
  export {
991
1000
  applyContextWatch,
992
- bucketKilo,
993
1001
  PAUSED_LINE,
994
1002
  PROTOCOL_ROOT_TEXT,
995
1003
  PROTOCOL_SUB_TEXT,
package/host/dispatch.js CHANGED
@@ -284,15 +284,40 @@ function annotationFixOf(args, config) {
284
284
  );
285
285
  }
286
286
 
287
+ /** The assembling session's own route, from the DSH agent-loop's assemble
288
+ * context (`agent.ts` passes `modelSelection` with every turn assembly;
289
+ * older builds and non-agent assemblies omit it). Null when absent or
290
+ * malformed — the session-route line then does not render (fail-open,
291
+ * like every capability read in this file family). */
292
+ function sessionRouteOf(context) {
293
+ const route = context === null || typeof context !== "object" ? undefined : context.modelSelection;
294
+ if (route === null || typeof route !== "object") return null;
295
+ const provider = typeof route.provider === "string" ? route.provider.trim() : "";
296
+ const model = typeof route.model === "string" ? route.model.trim() : "";
297
+ if (provider === "" || model === "") return null;
298
+ const effort = typeof route.reasoningEffort === "string" && route.reasoningEffort !== "" ? route.reasoningEffort : null;
299
+
300
+ return { provider, model, effort };
301
+ }
302
+
303
+
287
304
  /** The `[SWITCHMAN:POOLS]` block: anchor line, one line per configured lane,
288
305
  * then the guidance lines (byte-stable for a given configuration). Routes
289
306
  * outside the session's DSH-authorized child models carry a ⚠not-authorized
290
307
  * marker (after any @effort suffix) — teams mode only: with teamsMode OFF
291
308
  * the whitelist is never read, `state.authorized` stays null, and neither
292
- * the markers nor the drift guidance lines render. */
293
- function renderPoolsBlock(state) {
309
+ * the markers nor the drift guidance lines render. When the assembling
310
+ * context carries the session's own model selection, a `session route:`
311
+ * line follows the anchor. */
312
+ function renderPoolsBlock(state, sessionRoute = null) {
294
313
  const configured = state.lanePools.filter((pool) => pool.routes.length > 0);
295
314
  const lines = [`[SWITCHMAN:POOLS] configured=${configured.length}/6 enforce=${state.enforce}`];
315
+ if (sessionRoute !== null) {
316
+ lines.push(
317
+ `session route: ${sessionRoute.provider}/${sessionRoute.model}${sessionRoute.effort === null ? "" : `@${sessionRoute.effort}`}` +
318
+ ` — implicit dispatch (omitting provider/model/effort) gives the child THIS route; when it differs from the lane entries, only an explicit dispatch lands a lane model.`,
319
+ );
320
+ }
296
321
  let anyUnauthorized = false;
297
322
  for (const pool of state.lanePools) {
298
323
  if (pool.routes.length === 0) continue;
@@ -317,7 +342,7 @@ function renderPoolsBlock(state) {
317
342
  `Entries render as provider/model(TIER)@effort: (TIER) is the S/A/B/C capability grade and @effort a manually pinned reasoning effort — both are display-only annotations, NEVER parts of the model id.`,
318
343
  `Decompose before dispatching: provider/model(TIER)@effort → provider=provider, model=model, reasoning_effort=effort (a pin overrides the lane default); a route without @effort carries no per-route pin — fall back to the lane's default effort above.`,
319
344
  `Review delegations: anchor independence on the code AUTHOR's model, not the main session's (author = the agent that produced the diff: its explicit dispatch route, or the inherited session model when unspecified). Prefer a reviewer model different from the author's; also avoiding the session model is a bonus (its integration pass already covers that angle). If no pool entry differs from the author, same-model review is allowed — declare DOWNGRADED and state whether it is relative to the author or the session.`,
320
- `Explicit provider/model/effort on subagent/workflow calls is validated against this session's DSH-authorized child models (DSH Settings: allow agents to choose subagent models) — NOT against this table; when denied, discover allowed routes via list_subagent_models, or dispatch implicitly by omitting all three fields (the child then uses configured defaults or inherits the parent route).`,
345
+ `Explicit lane dispatch is the DEFAULT for subagent/workflow: name provider+model+reasoning_effort decomposed from the lane row (validated against this session's DSH-authorized child models — DSH Settings: allow agents to choose subagent models — NOT against this table). Implicit dispatch (omitting all three fields) is a LAST-RESORT fallback, never a shortcut: use it only after an explicit attempt was denied or list_subagent_models confirmed no authorized route, and declare IMPLICIT-DOWNGRADED in your report when you do — the child then uses configured defaults or inherits the parent route, so the lane's model is lost.`,
321
346
  );
322
347
  if (anyUnauthorized) {
323
348
  lines.push(
@@ -353,11 +378,11 @@ function applyDispatch(ctx, config) {
353
378
  name: "switchman:dispatch",
354
379
  order: 10400,
355
380
  interpolate: false,
356
- text: () => {
381
+ text: (context) => {
357
382
  const state = readDispatchState(ctx, config);
358
383
  const anyConfigured = state.lanePools.some((pool) => pool.routes.length > 0) || state.modelRank.length > 0;
359
384
  if (!anyConfigured) return "";
360
- return renderPoolsBlock(state);
385
+ return renderPoolsBlock(state, sessionRouteOf(context));
361
386
  },
362
387
  });
363
388
 
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: 25000 },
106
+ { field: "trimPressurePercent", min: 5, max: 100, def: 65 },
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: 10 },
110
+ { field: "trimLadderT2Tokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 8000 },
111
+ { field: "trimLadderT2MinBlocks", min: 2, max: 200, def: 3 },
112
+ { field: "trimLadderT3Tokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 6000 },
113
+ { field: "trimLadderT3MinBlocks", min: 2, max: 200, def: 2 },
114
+ { field: "trimMaxFoldsPerHour", min: 1, max: 60, def: 30 },
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/host/teams.js CHANGED
@@ -84,6 +84,7 @@ const TEAMS_SECTION_TEXT = [
84
84
  "## 五、团队触发与协作纪律",
85
85
  "- 需要多成员持久协作、共享任务板、消息往来的用 Agent Teams(满足任一即触发 spawn_teammate:可并行的多个独立子任务;量大且自包含;主上下文水位已高;需要角色分离);一次性、无需共享状态的单点调查用 subagent。",
86
86
  "- 写作域隔离:共享任务上写明互不重叠的 write_scopes;用 team_task_* 维护任务板与依赖顺序;Lead 汇总最终 diff、复核关键改动并运行测试;用 wait_agent 等到必需的 teammate 完成,绝不在结果未回前下最终结论。",
87
+ "- 子智能体消息通道:subagent/workflow 返回的子会话 id 只能经 child_message 桥接送达——send_message 只解析 teammate 名字,对子会话 id 必报 active teammate「…」not found;该报错不代表子会话死亡,先改用 child_message,不要据此重派或判定死亡。",
87
88
  ].join("\n");
88
89
 
89
90
  // ---- shared volatile-read helpers (mirrors dispatch.js) --------------------
@@ -580,4 +581,4 @@ function applyTeams(ctx, config) {
580
581
  syncChildMessageTool();
581
582
  }
582
583
 
583
- export { applyTeams, pumpTeamsOrchestration, SWITCHMAN_SETTINGS_NS, scheduleTeamsOrchestration, teamsBundleState, teamsSyncSnapshot };
584
+ export { applyTeams, pumpTeamsOrchestration, SWITCHMAN_SETTINGS_NS, TEAMS_SECTION_TEXT, scheduleTeamsOrchestration, teamsBundleState, teamsSyncSnapshot };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-switchman",
3
- "version": "1.10.3",
3
+ "version": "1.11.1",
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",