dsh-switchman 1.7.1 → 1.8.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.
package/host/config.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * - pool/rank dispatch pools and capability ranking (Phase 2)
13
13
  * - wm* context watermark control (Phase 3)
14
14
  * - teams* Agent Teams doctrine mode + whitelist sync (teams mode)
15
+ * - intent* intent-routing "auto" pseudo-model (classifier + candidates)
15
16
  *
16
17
  * Ported from opencode-switchman's opencode-switchman.jsonc surface
17
18
  * (oc/src/config.ts:23-51, oc/src/types.ts:281-344) onto DSH settings.
@@ -44,6 +45,34 @@ export const EffortEntrySchema = z.object({
44
45
  effort: z.string().min(1).required(),
45
46
  });
46
47
 
48
+ /** One intent-routing candidate: a real model route the classifier may pick,
49
+ * plus an optional reasoning-effort pin and a capability note the classifier
50
+ * reads. Every row carries a stable id; preset rows (id owned by
51
+ * host/intent-presets.js) keep a code-owned immutable note, while enabled /
52
+ * provider / model / effort are user-configured — provider/model may be
53
+ * empty while a preset waits for its route. effort/note follow
54
+ * RankEntrySchema.tier's optional style (no `.required()`: schemastery
55
+ * passes undefined through, so entries may omit either key). */
56
+ export const IntentCandidateSchema = z.object({
57
+ id: z.string(),
58
+ preset: z.boolean(),
59
+ enabled: z.boolean(),
60
+ provider: z.string(),
61
+ model: z.string(),
62
+ effort: z.string(),
63
+ note: z.string(),
64
+ });
65
+
66
+ /** One intent-layer route reference (classifier or fallback). Unlike
67
+ * ModelRouteSchema this accepts the "unset" `{provider:"", model:""}`
68
+ * pair the settings page round-trips — ModelRouteSchema's `.min(1)`
69
+ * would reject that exact declared default on every settings write.
70
+ * Consumers validate non-empty before trusting the route. */
71
+ const IntentRouteSchema = z.object({
72
+ provider: z.string(),
73
+ model: z.string(),
74
+ });
75
+
47
76
  /** All dsh-switchman settings. Field names are the settings mutation paths. */
48
77
  export const Config = z.object({
49
78
  // --- Phase 0: UI locale ------------------------------------------------
@@ -99,13 +128,13 @@ export const Config = z.object({
99
128
 
100
129
  // --- Phase 3: context watermark control --------------------------------
101
130
  /** Soft watermark: advisory banner + delegation nudge (tokens). */
102
- wmSoftTokens: z.number().min(1000).default(50000).volatile(),
131
+ wmSoftTokens: z.number().min(1000).default(80000).volatile(),
103
132
  /** Hard watermark: read budget tightens, wrap-up advised (tokens). */
104
- wmHardTokens: z.number().min(2000).default(90000).volatile(),
133
+ wmHardTokens: z.number().min(2000).default(120000).volatile(),
105
134
  /** Force watermark: backup + compaction handover (tokens). */
106
- wmForceTokens: z.number().min(3000).default(130000).volatile(),
135
+ wmForceTokens: z.number().min(3000).default(150000).volatile(),
107
136
  /** Per-call read budget for read/glob/grep (tokens). */
108
- wmReadBudgetTokens: z.number().min(200).default(1500).volatile(),
137
+ wmReadBudgetTokens: z.number().min(200).default(3000).volatile(),
109
138
  /** Hard-tier behavior: cap reads and advise, or deny outright. */
110
139
  wmDenyMode: z.union(["cap", "deny"]).default("cap").volatile(),
111
140
  /** Force tier triggers backup + compaction automatically. */
@@ -132,6 +161,66 @@ export const Config = z.object({
132
161
  * wholesale. The whitelist only applies to top-level sessions composed
133
162
  * afterwards. */
134
163
  syncWhitelist: z.boolean().default(false).volatile(),
164
+
165
+ // --- Intent recognition: classifier-routed "auto" model ----------------
166
+ /** Master switch for intent routing (design: .dsh-switchman/plans/
167
+ * intent-routing.md §2). The adapter stays mounted either way; OFF
168
+ * only hides the catalog entry (listModels returns [], the selector
169
+ * drops the empty group) — a session already on Auto routes through
170
+ * the documented fallback chain (routedCurrent → intentFallbackRoute
171
+ * → first candidate) with NO classifier call. */
172
+ intentEnabled: z.boolean().default(false).volatile(),
173
+ /** Small-model route that classifies the conversation against the
174
+ * candidates. Empty = unset; classification is skipped (fail-open). */
175
+ intentClassifierRoute: IntentRouteSchema.default({ provider: "", model: "" }).volatile(),
176
+ /** Optional reasoning-effort pin for the classifier call ("" = adapter
177
+ * default). */
178
+ intentClassifierEffort: z.string().default("").volatile(),
179
+ /** Classifier channel; must be an explicit choice. "chat" calls the
180
+ * intentClassifierRoute above through its provider's standard chat
181
+ * API with a strict-JSON instruction. "systemone" = direct HTTP to a
182
+ * hand-configured ollama /v1/systemone decision endpoint — no
183
+ * provider, uses intentClassifierEndpoint +
184
+ * intentClassifierSystemoneModel. */
185
+ intentClassifierKind: z.union(["chat", "systemone"]).default("chat").volatile(),
186
+ /** Full URL of the ollama /v1/systemone endpoint for kind "systemone"
187
+ * (e.g. http://127.0.0.1:11434/v1/systemone). Empty = unset; that
188
+ * kind fails open to the fallback chain. */
189
+ intentClassifierEndpoint: z.string().default("").volatile(),
190
+ /** Model name sent to the decision endpoint for kind "systemone"
191
+ * (free text, e.g. tev1:4b-mxfp8). Independent of the llm provider
192
+ * catalog — no provider registration needed. Empty = unset; that
193
+ * kind fails open to the fallback chain. */
194
+ intentClassifierSystemoneModel: z.string().default("").volatile(),
195
+ /** Independent candidate list the "auto" pseudo-model may route to (NOT
196
+ * poolMain). Each entry: {id, preset?, enabled?, provider, model,
197
+ * effort?, note?}; readers merge the built-in presets from
198
+ * host/intent-presets.js (see mergeIntentCandidates). */
199
+ intentCandidates: z.array(IntentCandidateSchema).default([]).volatile(),
200
+ /** Provider prompt-cache reuse window: while the current route stays
201
+ * below the switch-confidence threshold, keep it for this many
202
+ * seconds since the last applied routing. */
203
+ intentCacheTtlSeconds: z.number().min(0).default(300).volatile(),
204
+ /** Classifier confidence (0-100) required to leave the current model. */
205
+ intentSwitchConfidence: z.number().min(0).max(100).default(80).volatile(),
206
+ /** Classification-call budget in ms; timeout/abort fails open to the
207
+ * fallback chain. */
208
+ intentTimeoutMs: z.number().min(200).default(60000).volatile(),
209
+ /** Recent user/assistant messages fed to the classifier. */
210
+ intentHistoryMessages: z.number().min(1).max(20).default(6).volatile(),
211
+ /** Total character budget for that conversation excerpt. */
212
+ intentHistoryMaxChars: z.number().min(200).max(20000).default(2400).volatile(),
213
+ /** Context window (tokens) declared by the "auto" pseudo-model. Purely
214
+ * advisory metadata: drives the usage popover and the auto-compaction
215
+ * pressure math for sessions routed through Auto. While wmAutoHandover
216
+ * is on, the effective declared value is floored at
217
+ * ceil(wmForceTokens / 0.8) so the smooth handover always fires before
218
+ * the 0.8×-window auto-compaction (see effectiveContextWindow in
219
+ * host/intent.js). */
220
+ intentContextWindow: z.number().min(1024).default(200000).volatile(),
221
+ /** Optional last-resort route when the classifier fails and no previous
222
+ * routed model exists; empty = the first candidate. */
223
+ intentFallbackRoute: IntentRouteSchema.default({ provider: "", model: "" }).volatile(),
135
224
  });
136
225
 
137
226
  /** The six dispatch lanes, in display order. */
@@ -4,11 +4,17 @@
4
4
  * src/index.ts:386-459 (banner rendering) and 2066-2110 (auto-handover with
5
5
  * its anti-deadlock lesson) onto DSH services:
6
6
  *
7
- * - One dynamic systemPrompt section 'switchman:watermark' (order 10500)
8
- * renders the `[SWITCHMAN:WATERMARK]` banner from the live token meter
9
- * (ctx.tokenMeter.measure(agent.session).totalTokens). Root sessions get
10
- * the tiered banner (ok/soft/hard/force/paused); subagent sessions get a
11
- * compact cap-aware banner. The text function is side-effect free.
7
+ * - KV-cache invariant: the systemPrompt section 'switchman:watermark'
8
+ * (order 10500) carries only byte-stable protocol text (root/subagent
9
+ * variants, no live numbers), so the request head never changes
10
+ * mid-session; the live tier + bucketed used reading travel as the
11
+ * 'switchman:watermark' runtime context (ctx.systemPrompt.context,
12
+ * order 300, required) — DSH renders contexts into the tail-appended
13
+ * "Current runtime context" snapshot, which is emitted as a new trailing
14
+ * user message only when its text changes, preserving the cached prefix
15
+ * by construction. Root sessions get tiers ok (silent)/soft/hard/force
16
+ * plus a static paused line; subagent sessions get soft/hard/force with
17
+ * their own force cap. Both text functions are side-effect free.
12
18
  * - Read budget double gate: `tools/post-execute` accounts read/glob/grep/
13
19
  * bash result text (~len/3.5 tokens) per session per turn; `tools/
14
20
  * pre-execute` denies or caps read/glob/grep once the budget is exhausted
@@ -133,9 +139,9 @@ function isRoot(ctx, agent) {
133
139
  /** Live threshold snapshot from the volatile settings. */
134
140
  function readThresholds(config) {
135
141
  return {
136
- soft: readNumber(config.wmSoftTokens, 50000),
137
- hard: readNumber(config.wmHardTokens, 90000),
138
- force: readNumber(config.wmForceTokens, 130000),
142
+ soft: readNumber(config.wmSoftTokens, 80000),
143
+ hard: readNumber(config.wmHardTokens, 120000),
144
+ force: readNumber(config.wmForceTokens, 150000),
139
145
  };
140
146
  }
141
147
 
@@ -150,64 +156,89 @@ function measureNow(ctx, agent) {
150
156
  }
151
157
  }
152
158
 
153
- // ---- banner rendering (pure given state + measurement) ---------------------
159
+ // ---- watermark rendering (pure given state + measurement) ------------------
154
160
 
155
- /** Root-session banner: byte-stable anchor line + tier advice lines plus
161
+ /** Bucket width for the live used reading. 5k steps keep the tail snapshot
162
+ * emission rate low (the snapshot re-emits only when its joined text
163
+ * changes); tier crossings change the line immediately regardless. */
164
+ const USED_BUCKET_TOKENS = 5000;
165
+
166
+ /** `~50k`-style bucketed reading for the live watermark line. */
167
+ function bucketKilo(tokens) {
168
+ return `${Math.round(tokens / USED_BUCKET_TOKENS) * (USED_BUCKET_TOKENS / 1000)}k`;
169
+ }
170
+
171
+ /** Byte-stable protocol text for root sessions: no live numbers, so the
172
+ * request head stays byte-identical for the whole session (KV-cache
173
+ * invariant); the numbers live in the tail context line only. */
174
+ const PROTOCOL_ROOT_TEXT = [
175
+ "[SWITCHMAN:WATERMARK] protocol: this session is guarded by a context watermark measured every step.",
176
+ "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 (backup + document + compaction + continuation) may run and will tell you to end the turn.",
177
+ "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.",
178
+ ].join("\n");
179
+
180
+ /** Byte-stable protocol text for subagent sessions (same invariant). */
181
+ const PROTOCOL_SUB_TEXT = [
182
+ "[SWITCHMAN:WATERMARK] protocol: this subagent session is guarded by a context watermark measured every step.",
183
+ "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.",
184
+ "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.",
185
+ ].join("\n");
186
+
187
+ /** Static paused line (numbers omitted on purpose: enforcement is off, and a
188
+ * frozen string emits once and never churns the snapshot again). */
189
+ const PAUSED_LINE =
190
+ "[SWITCHMAN:WATERMARK] paused — measurement continues, but every watermark enforcement stays off until /ctx-resume.";
191
+
192
+ /** Root-session live line: "" at ok (token economy), tier advice lines plus
156
193
  * the live handover progress (phase/attempt) and auto-cooldown state. */
157
- function renderRootBanner(level, used, th, status) {
194
+ function renderRootWatermark(level, used, th, status) {
195
+ if (level === "ok") return "";
158
196
  const head =
159
- `[SWITCHMAN:WATERMARK] level=${level} used=${kilo(used)}` +
160
- ` budget=${kilo(th.soft)}/${kilo(th.hard)}/${kilo(th.force)}`;
161
- if (level === "ok") return head;
162
- if (level === "paused")
163
- return `${head}\nPAUSED by /ctx-pause — measurement continues, but every watermark enforcement stays off until /ctx-resume.`;
164
- const lines = [head];
165
- if (level === "soft") lines.push(`soft tier: prefer delegating lightweight work (subagent/workflow) over reading more context yourself.`);
166
- if (level === "hard") lines.push(`hard tier: stop broad reading (read/glob/grep are budget-limited this turn) and start wrapping up.`);
167
- if (level === "force") {
168
- lines.push(`force tier: MANDATORY wrap-up now — produce the final answer from what you already hold.`);
169
- const phase = status?.phase;
170
- if (phase === "backup") {
171
- lines.push(`context handover running: forking the backup session and writing the handover document…`);
172
- } else if (phase === "compacting") {
173
- lines.push(`context handover running: compaction attempt ${status?.attempt ?? "?"} (retries while the turn is still running)…`);
174
- } else if (phase === "continuation") {
175
- lines.push(`context handover completing: waking this session to fill in the document and continue the task…`);
176
- } else if (status?.autoHandover) {
177
- lines.push(
178
- status?.cooldownMin > 0
179
- ? `automatic handover cooldown: ${status.cooldownMin} min left; run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to force one now.`
180
- : `an automatic context handover (backup + document + compaction + continuation) is armed and will fire at the next step boundary${status?.tool ? "; calling the ctx_handover tool triggers it immediately" : ""}.`,
181
- );
182
- } else {
183
- lines.push(`run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to trigger a manual context handover.`);
184
- }
197
+ `[SWITCHMAN:WATERMARK] ${level} tier — used ~${bucketKilo(used)}` +
198
+ ` of budget ${kilo(th.soft)}/${kilo(th.hard)}/${kilo(th.force)}`;
199
+ if (level === "soft") return `${head}\nsoft tier: prefer delegating lightweight work (subagent/workflow) over reading more context yourself.`;
200
+ if (level === "hard") return `${head}\nhard tier: stop broad reading (read/glob/grep are budget-limited this turn) and start wrapping up.`;
201
+ const lines = [head, "force tier: MANDATORY wrap-up now — produce the final answer from what you already hold."];
202
+ const phase = status?.phase;
203
+ if (phase === "backup") {
204
+ lines.push(`context handover running: forking the backup session and writing the handover document…`);
205
+ } else if (phase === "compacting") {
206
+ lines.push(`context handover running: compaction attempt ${status?.attempt ?? "?"} (retries while the turn is still running)…`);
207
+ } else if (phase === "continuation") {
208
+ lines.push(`context handover completing: waking this session to fill in the document and continue the task…`);
209
+ } else if (status?.autoHandover) {
210
+ lines.push(
211
+ status?.cooldownMin > 0
212
+ ? `automatic handover cooldown: ${status.cooldownMin} min left; run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to force one now.`
213
+ : `an automatic context handover (backup + document + compaction + continuation) is armed and will fire at the next step boundary${status?.tool ? "; calling the ctx_handover tool triggers it immediately" : ""}.`,
214
+ );
215
+ } else {
216
+ lines.push(`run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to trigger a manual context handover.`);
185
217
  }
186
218
  return lines.join("\n");
187
219
  }
188
220
 
189
- /** Subagent-session banner: the same soft/hard/force absolute-token tiers
190
- * as the root banner ("" while ok — token economy), with subagent-flavored
221
+ /** Subagent-session live line: the same soft/hard/force absolute-token tiers
222
+ * as the root line ("" while ok — token economy), with subagent-flavored
191
223
  * advice and the HANDOFF block due at the force tier. Pausing never applies
192
224
  * to subagents (oc index.ts:187-190). */
193
- function renderSubBanner(used, th) {
225
+ function renderSubWatermark(used, th) {
194
226
  const level = levelOf(used, th);
195
- const head =
196
- `[SWITCHMAN:WATERMARK] subagent level=${level} used=${kilo(used)}` +
197
- ` budget=${kilo(th.soft)}/${kilo(th.hard)}/${kilo(th.force)}`;
198
227
  if (level === "ok") return "";
199
- if (level === "soft")
200
- return `${head}\nsoft tier: keep reads narrow and drop bulky raw output.`;
201
- if (level === "hard")
202
- return `${head}\nhard tier: stop broad reading; start wrapping toward the final summary.`;
228
+ const head =
229
+ `[SWITCHMAN:WATERMARK] subagent ${level} tier — used ~${bucketKilo(used)}` +
230
+ ` of budget ${kilo(th.soft)}/${kilo(th.hard)}/${kilo(th.force)}`;
231
+ if (level === "soft") return `${head}\nsoft tier: keep reads narrow and drop bulky raw output.`;
232
+ if (level === "hard") return `${head}\nhard tier: stop broad reading; start wrapping toward the final summary.`;
203
233
  return (
204
234
  `${head}\nforce tier: stop reading and return the final summary now.\n` +
205
235
  `End the FINAL message with a HANDOFF block: completed / key findings (file:line) / remaining / next steps.`
206
236
  );
207
237
  }
208
238
 
209
- /** Whole banner for one assembly; "" whenever anything needed is missing. */
210
- function renderBanner(ctx, config, state, agent) {
239
+ /** Whole live line for one assembly (the tail context); "" whenever anything
240
+ * needed is missing or the session sits at the silent ok tier. */
241
+ function renderWatermarkContext(ctx, config, state, agent) {
211
242
  try {
212
243
  const sessionId = sessionIdOf(agent);
213
244
  if (sessionId === null || agent.session === undefined) return "";
@@ -220,16 +251,18 @@ function renderBanner(ctx, config, state, agent) {
220
251
  // (enabled by the wmSubagentCap toggle, 0/0 = follow the root set).
221
252
  const subForce = readBool(config.wmSubagentCap, true) ? readNumber(config.wmSubagentForceTokens, 0) : 0;
222
253
  const subTh = subForce > 0 ? { ...th, force: subForce } : th;
223
- return renderSubBanner(used, subTh);
254
+ return renderSubWatermark(used, subTh);
224
255
  }
225
- const level = state.paused.has(sessionId) ? "paused" : levelOf(used, th);
256
+ if (state.paused.has(sessionId)) return PAUSED_LINE;
226
257
  const live = handoverOf(sessionId);
227
258
  const elapsed = Date.now() - (state.lastHandover.get(sessionId) ?? 0);
228
- return renderRootBanner(level, used, th, {
259
+ // Cooldown quantized to 5-minute steps so the emitted line stays
260
+ // byte-stable between steps (snapshot re-emits only on change).
261
+ return renderRootWatermark(levelOf(used, th), used, th, {
229
262
  autoHandover: readBool(config.wmAutoHandover, true),
230
263
  phase: live?.phase,
231
264
  attempt: live?.attempt,
232
- cooldownMin: Math.max(0, Math.ceil((HANDOVER_COOLDOWN_MS - elapsed) / 60_000)),
265
+ cooldownMin: Math.max(0, Math.ceil((HANDOVER_COOLDOWN_MS - elapsed) / 300_000) * 5),
233
266
  tool: state.handoverTool ?? undefined,
234
267
  });
235
268
  } catch {
@@ -298,13 +331,40 @@ function applyContextWatch(ctx, config) {
298
331
  };
299
332
  const warn = (message) => ctx.logger?.warn?.(`dsh-switchman: ${message}`);
300
333
 
301
- // --- A. watermark banner -------------------------------------------------
334
+ // --- A. watermark: byte-stable head protocol + live tail context --------
335
+ // KV-cache invariant (see the file header): everything before the
336
+ // conversation history must stay byte-identical for the whole session,
337
+ // so the head section carries only static protocol text while ALL live
338
+ // numbers travel as runtime context. DSH joins contexts into the
339
+ // "Current runtime context" snapshot and appends it at the tail as a new
340
+ // user message only when its text changes — append-only by construction,
341
+ // the cached prefix is never touched.
302
342
  ctx.systemPrompt.section({
303
343
  name: "switchman:watermark",
304
344
  order: 10500,
305
345
  interpolate: false,
306
- text: (context) => renderBanner(ctx, config, state, context?.agent),
346
+ text: (context) => (isRoot(ctx, context?.agent) ? PROTOCOL_ROOT_TEXT : PROTOCOL_SUB_TEXT),
307
347
  });
348
+ // Capability check first: on a host without the runtime-context API an
349
+ // optional chain would short-circuit silently — the live watermark line
350
+ // would vanish with no log at all. Warn once so it stays diagnosable.
351
+ if (typeof ctx.systemPrompt?.context !== "function") {
352
+ warn("systemPrompt.context API unavailable on this DSH host — live watermark line disabled (static head protocol remains)");
353
+ } else {
354
+ try {
355
+ ctx.systemPrompt.context({
356
+ name: "switchman:watermark",
357
+ // After the core placements (WORKING_DIRECTORY 100 … WEB_SURFACE
358
+ // 210), so the watermark line closes the snapshot.
359
+ order: 300,
360
+ required: true,
361
+ interpolate: false,
362
+ text: (context) => renderWatermarkContext(ctx, config, state, context?.agent),
363
+ });
364
+ } catch (error) {
365
+ warn(`watermark context registration failed: ${error?.message ?? error}`);
366
+ }
367
+ }
308
368
 
309
369
  // --- C. handover execution: borrow the session's own /compact -----------
310
370
  // The desktop profile provides compaction ONLY inside each preset's
@@ -805,7 +865,7 @@ function applyContextWatch(ctx, config) {
805
865
  if (sessionId !== null && ACCOUNT_TOOLS.has(exec?.name ?? "")) {
806
866
  const entry = accountRead(state, sessionId, resultTokens(result));
807
867
  if (GATED_TOOLS.has(exec.name) && !state.paused.has(sessionId)) {
808
- const budget = readNumber(config.wmReadBudgetTokens, 1500);
868
+ const budget = readNumber(config.wmReadBudgetTokens, 3000);
809
869
  const ceiling = budget * CEILING_MULTIPLE;
810
870
  const used = measureNow(ctx, exec.agent);
811
871
  const th = readThresholds(config);
@@ -841,7 +901,7 @@ function applyContextWatch(ctx, config) {
841
901
  if (!GATED_TOOLS.has(exec?.name ?? "")) return next();
842
902
  const sessionId = sessionIdOf(exec?.agent);
843
903
  if (sessionId === null || state.paused.has(sessionId)) return next();
844
- const budget = readNumber(config.wmReadBudgetTokens, 1500);
904
+ const budget = readNumber(config.wmReadBudgetTokens, 3000);
845
905
  if (turnUsed(state, sessionId) < budget * CEILING_MULTIPLE) return next();
846
906
  const used = measureNow(ctx, exec.agent);
847
907
  if (used === null) return next();
@@ -957,4 +1017,12 @@ function applyContextWatch(ctx, config) {
957
1017
  }
958
1018
  }
959
1019
 
960
- export { applyContextWatch };
1020
+ export {
1021
+ applyContextWatch,
1022
+ bucketKilo,
1023
+ PAUSED_LINE,
1024
+ PROTOCOL_ROOT_TEXT,
1025
+ PROTOCOL_SUB_TEXT,
1026
+ renderRootWatermark,
1027
+ renderSubWatermark,
1028
+ };
package/host/dispatch.js CHANGED
@@ -7,6 +7,16 @@
7
7
  * `tools/pre-execute` waterfall listener denies `subagent` calls that name
8
8
  * a provider+model outside every configured pool and modelRank.
9
9
  *
10
+ * The same listener also runs an annotation sanitation pass in EVERY mode:
11
+ * a model id copied verbatim off the rendered table can embed its
12
+ * display-only `(TIER)`/`@effort` annotations (the recommendation table is
13
+ * the one string an LLM is most tempted to copy), which DSH's exact-match
14
+ * child-route whitelist then rejects with a generic binding error that
15
+ * pushes the caller back to implicit dispatch — silently losing the lane's
16
+ * route. The sanitation denies such calls with the exact corrected
17
+ * arguments instead (exec.arguments is deep-frozen by the runtime, so a
18
+ * deny is the only correction channel `tools/pre-execute` offers).
19
+ *
10
20
  * Model-facing text is English and byte-stable for a given configuration
11
21
  * (the anchor line never varies with UI locale). All reads go through the
12
22
  * live volatile references, so settings edits apply without a plugin
@@ -177,6 +187,103 @@ function effortMapOf(pool) {
177
187
  return effortOf;
178
188
  }
179
189
 
190
+ /** Trailing display annotations renderPoolsBlock appends to a pool entry:
191
+ * the `(S)/(A)/(B)/(C)` capability-grade parenthetical (uppercase only —
192
+ * that is what renderRoute emits), the `@effort` pin, and the
193
+ * `⚠not-authorized` drift marker. */
194
+ const TRAILING_EFFORT = /@([A-Za-z0-9._-]+)$/;
195
+ const TRAILING_TIER = /\([SABC]\)$/;
196
+ const TRAILING_MARKER = /\s*⚠not-authorized$/u;
197
+
198
+ /** Strip every trailing display annotation from a model id
199
+ * ("m(A)@high" → "m"), looped so any order reduces; an unannotated id
200
+ * round-trips unchanged. */
201
+ function stripTrailingAnnotations(model) {
202
+ let s = model.trim();
203
+ for (;;) {
204
+ const next = s.replace(TRAILING_MARKER, "").replace(TRAILING_EFFORT, "").replace(TRAILING_TIER, "").trim();
205
+ if (next === s) return s;
206
+ s = next;
207
+ }
208
+ }
209
+
210
+ /** Folded-key → first configured {provider, model} route over every lane
211
+ * and modelRank — the enforce gate's matcher, factored out so the
212
+ * sanitation pass shares it. Empty when nothing is configured. */
213
+ function configuredRoutes(config) {
214
+ const routes = new Map();
215
+ const add = (route) => {
216
+ if (!isModelRoute(route)) return;
217
+ const key = normalizeModelKey(route.provider, route.model);
218
+ if (key !== "" && !routes.has(key)) routes.set(key, { provider: route.provider, model: route.model });
219
+ };
220
+ for (const lane of LANES) for (const route of readList(config[laneField(lane)])) add(route);
221
+ for (const entry of readList(config.modelRank)) add(entry);
222
+ return routes;
223
+ }
224
+
225
+ /** First effort pin configured for one folded route key, across every
226
+ * lane's effort list (null when the route carries no pin) — completes a
227
+ * corrective hint whose copied id kept only the (TIER) annotation. */
228
+ function configuredEffortPin(config, key) {
229
+ for (const lane of LANES) {
230
+ for (const entry of readList(config[laneEffortsField(lane)])) {
231
+ if (!isEffortEntry(entry)) continue;
232
+ if (normalizeModelKey(entry.provider, entry.model) === key) return entry.effort;
233
+ }
234
+ }
235
+ return null;
236
+ }
237
+
238
+ /** Corrective deny reason for one delegation call whose model argument
239
+ * embeds pool-table display annotations, or null to pass through. The
240
+ * stripped id must match a configured route (folded, like the enforce
241
+ * gate) — an unknown id is left to DSH's own route validation, so an
242
+ * exotic-but-real id that merely looks annotated never gets denied.
243
+ * Covers the plain suffix copy ("m(A)", "m(A)@high"), the whole-cell copy
244
+ * including the ⚠not-authorized drift marker, and the provider-combined
245
+ * copy ("provider/m(A)" with provider left empty). The reasoning_effort
246
+ * hint rides along when the copied string carried the @effort annotation
247
+ * or the matched route holds a configured pin, and the call did not
248
+ * already set reasoning_effort itself. */
249
+ function annotationFixOf(args, config) {
250
+ const model = typeof args?.model === "string" ? args.model.trim() : "";
251
+ if (model === "") return null;
252
+ const providerGiven = typeof args?.provider === "string" && args.provider.trim() !== "";
253
+ let provider = providerGiven ? args.provider.trim() : "";
254
+ let modelPart = model;
255
+ if (!providerGiven && modelPart.includes("/")) {
256
+ const cut = modelPart.lastIndexOf("/");
257
+ provider = modelPart.slice(0, cut).trim();
258
+ modelPart = modelPart.slice(cut + 1).trim();
259
+ }
260
+ modelPart = modelPart.replace(TRAILING_MARKER, "").trim();
261
+ const effortMatch = TRAILING_EFFORT.exec(modelPart);
262
+ const stripped = stripTrailingAnnotations(modelPart);
263
+ if (stripped === modelPart || stripped === "") return null;
264
+ const key = normalizeModelKey(provider, stripped);
265
+ const matched = key === "" ? undefined : configuredRoutes(config).get(key);
266
+ if (matched === undefined) return null;
267
+ // The re-dispatch must name BOTH fields whenever the original call left
268
+ // provider empty (provider+model go together on these tools), even when
269
+ // the provider was recoverable from a combined "provider/model(…)" copy.
270
+ // A provider-given whole-cell copy still carries its "provider/" prefix
271
+ // inside the stripped id — the matched route's clean model is the only
272
+ // suggestion DSH's exact-match whitelist will actually accept.
273
+ const modelFix = stripped.includes("/") ? matched.model : stripped;
274
+ const fields = providerGiven
275
+ ? `model "${modelFix}"`
276
+ : `provider "${provider !== "" ? provider : matched.provider}", model "${modelFix}"`;
277
+ const hasExplicitEffort = typeof args?.reasoning_effort === "string" && args.reasoning_effort.trim() !== "";
278
+ const pinned = effortMatch !== null ? effortMatch[1] : configuredEffortPin(config, key);
279
+ const effort = hasExplicitEffort || pinned === null ? "" : ` and reasoning_effort "${pinned}"`;
280
+ return (
281
+ `[SWITCHMAN:POOLS] the model argument "${args.model}" embeds pool-table display annotations — ` +
282
+ `the (S/A/B/C) capability grade and any @effort suffix are annotations, NOT parts of the model id. ` +
283
+ `Re-dispatch this call with ${fields}${effort}, keeping every other argument unchanged.`
284
+ );
285
+ }
286
+
180
287
  /** The `[SWITCHMAN:POOLS]` block: anchor line, one line per configured lane,
181
288
  * then the guidance lines (byte-stable for a given configuration). Routes
182
289
  * outside the session's DSH-authorized child models carry a ⚠not-authorized
@@ -207,7 +314,8 @@ function renderPoolsBlock(state) {
207
314
  `When delegating via subagent/workflow, pick a model from the lane matching the task category; spawn_teammate takes no model argument (lanes do not apply to it).`,
208
315
  `Lanes marked * are in manual order: dispatch follows the listed order exactly (the head is the first choice).`,
209
316
  `effort: economy=medium mechanical=medium main=medium (high for complex tasks) hard=high vision=multimodal-capable model review=high; an unconfigured lane uses the system default model.`,
210
- `A route suffixed @effort has a manually pinned reasoning effort — pass it as reasoning_effort when delegating to that model (it overrides the lane default).`,
317
+ `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
+ `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.`,
211
319
  `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.`,
212
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).`,
213
321
  );
@@ -253,34 +361,38 @@ function applyDispatch(ctx, config) {
253
361
  },
254
362
  });
255
363
 
256
- // Enforce gate (tools/pre-execute waterfall): deny decisions are returned
257
- // directly WITHOUT calling next() — { kind: "deny", reason } — while every
258
- // other path must call and return next(). Only subagent calls that name
259
- // both provider and model are checked; model-less calls use the system
260
- // default and pass through untouched.
364
+ // Annotation sanitation (every mode) + enforce gate (enforce mode only).
365
+ // Both are tools/pre-execute waterfall listeners in one registration:
366
+ // deny decisions are returned directly WITHOUT calling next() —
367
+ // { kind: "deny", reason } — while every other path must call and
368
+ // return next(). The sanitation covers subagent and subagent_fork; the
369
+ // enforce gate below stays subagent-only with both provider and model
370
+ // named; model-less calls use the system default and pass through.
261
371
  ctx.on("tools/pre-execute", (exec, next) => {
262
372
  try {
373
+ if (exec?.name !== "subagent" && exec?.name !== "subagent_fork") return next();
374
+ const args = exec.arguments;
375
+
376
+ // Annotation sanitation: a model id copied verbatim off the
377
+ // rendered table embeds its display-only (TIER)/@effort/⚠
378
+ // annotations; DSH's exact-match whitelist would reject it with
379
+ // a generic binding error that pushes the caller back to
380
+ // implicit dispatch, losing the lane's route. Deny with the
381
+ // exact corrected arguments instead — one immediate retry on
382
+ // the intended route.
383
+ const fix = annotationFixOf(args, config);
384
+ if (fix !== null) return { kind: "deny", reason: fix };
385
+
386
+ // Enforce gate: unchanged semantics, now sharing the factored
387
+ // configuredRoutes matcher (same folded keys as before).
263
388
  if (readField(config.dispatchEnforce) !== "enforce") return next();
264
389
  if (exec?.name !== "subagent") return next();
265
- const args = exec.arguments;
266
390
  if (typeof args?.provider !== "string" || args.provider === "") return next();
267
391
  if (typeof args?.model !== "string" || args.model === "") return next();
268
392
  const key = normalizeModelKey(args.provider, args.model);
269
393
  if (key === "") return next();
270
394
 
271
- const allowed = new Set();
272
- for (const lane of LANES) {
273
- for (const route of readList(config[laneField(lane)])) {
274
- if (!isModelRoute(route)) continue;
275
- const routeKey = normalizeModelKey(route.provider, route.model);
276
- if (routeKey !== "") allowed.add(routeKey);
277
- }
278
- }
279
- for (const entry of readList(config.modelRank)) {
280
- if (!isModelRoute(entry)) continue;
281
- const rankKey = normalizeModelKey(entry.provider, entry.model);
282
- if (rankKey !== "") allowed.add(rankKey);
283
- }
395
+ const allowed = configuredRoutes(config);
284
396
  if (allowed.size === 0 || allowed.has(key)) return next();
285
397
 
286
398
  const state = readDispatchState(ctx, config);