dsh-switchman 1.11.1 → 1.11.3

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/README.md CHANGED
@@ -154,7 +154,7 @@ All keys live in Settings → dsh-switchman; defaults from `host/config.js`:
154
154
 
155
155
  ## How it works
156
156
 
157
- - The host half (`index.js` + `host/`) injects the byte-stable system-prompt sections (language / lanes / watermark protocol / teams) plus the live watermark line through the tail runtime-context snapshot (a channel that appends instead of rewriting the prompt head, so the provider prompt cache stays intact), the read-budget plus enforce double gate, and four slash commands (the ctx trio + `/vision`). A saved setting takes effect at the very next prompt assembly — no restart.
157
+ - The host half (`index.js` + `host/`) injects the byte-stable system-prompt sections (language / lanes / watermark protocol / teams / PTC guard) plus the live watermark line through the tail runtime-context snapshot (a channel that appends instead of rewriting the prompt head, so the provider prompt cache stays intact), the read-budget plus enforce double gate, and four slash commands (the ctx trio + `/vision`). A saved setting takes effect at the very next prompt assembly — no restart. In PTC sessions (programmatic tool calls) the PTC guard renders as the final section and restates that every named tool must be invoked inside a `run_code` program (`await tools.name(args)`), making `unknown tool` rejections much less likely when doctrines or skill catalogs name tools directly (a prompt-level mitigation, not a hard guarantee).
158
158
  - The client half (`client.js`) renders the settings page, the ⚡ badge and ◇ model marker in the session header, and the live handover indicator; config snapshots are read and written through the Host route family.
159
159
  - The "Switchman Dispatch Center" entry in the home sidebar opens that same settings page as a center panel in one click; the original settings entry stays.
160
160
  - `cordis.patch.yml` keeps the factory preset plugin list fully intact and only extends the persona suffix; the Agent Teams tools themselves come from the factory bundle and switch on automatically with teams mode.
@@ -162,7 +162,7 @@ All keys live in Settings → dsh-switchman; defaults from `host/config.js`:
162
162
  ## Maintenance notes
163
163
 
164
164
  - After a DSH upgrade, if the factory preset plugin list changed, re-sync `cordis.patch.yml` from the new `presets/*.patch.yml` (keeping the doctrine suffix and the compaction arbitration — `auto: false` on the `compaction-basic` rows), then reinstall.
165
- - Protocol lines (`[SWITCHMAN:LANG|POOLS|WATERMARK|TEAMS]`) are deliberately English and byte-stable — don't localize them.
165
+ - Protocol lines (`[SWITCHMAN:LANG|POOLS|WATERMARK|TEAMS|PTC]`) are deliberately English and byte-stable — don't localize them.
166
166
  - `npm pack --dry-run` should keep the audited 60-file / ~380 kB tarball shape (~1.26 MB unpacked). `docs/` stays out of the package (it is not in `package.json files`), while the 11 `README.*` translations are always shipped by npm.
167
167
  - **Zero runtime dependencies — a hard invariant.** Host-plane packages (`@deepseek-ai/schemastery`, cordis, …) are declared as `peerDependencies` only, and the running DSH satisfies them from its own copy. An ordinary `dependencies` entry is physically installed into the profile workspace (`<profile>/node_modules/…`), where it shadows the host's copy for every other profile-resident plugin — this is exactly what crashed the dshmarket plugin market while 1.7.0 carried schemastery as a dependency (two schemastery instances in one process; the market's schemas no longer matched the host's). `scripts/validate.mjs` fails if a `dependencies` entry ever reappears, so installing, updating, or removing dsh-switchman can never touch what dshmarket or any other community bundle resolves at runtime.
168
168
 
package/client.js CHANGED
@@ -198,6 +198,7 @@ window.__ModuleLoader__.load({
198
198
  { field: 'wmForceTokens', min: 3000, def: 150000 },
199
199
  { field: 'wmReadBudgetTokens', min: 200, def: 3000 },
200
200
  { field: 'wmSubagentForceTokens', min: 0, def: 0 },
201
+ { field: 'wmSubagentWindowRatio', min: 0.1, def: 0.75 },
201
202
  ];
202
203
 
203
204
  /** The three thresholds that must keep a strict ascending order. */
@@ -213,6 +214,10 @@ window.__ModuleLoader__.load({
213
214
  label: 'wmSubagentForceLabel',
214
215
  help: 'wmSubagentForceHelp',
215
216
  },
217
+ wmSubagentWindowRatio: {
218
+ label: 'wmSubagentWindowRatioLabel',
219
+ help: 'wmSubagentWindowRatioHelp',
220
+ },
216
221
  };
217
222
 
218
223
  /** Intent-routing classifier kinds (design: intent-routing.md §2;
@@ -367,12 +372,16 @@ window.__ModuleLoader__.load({
367
372
  /** Stable identity of one provider/model route. */
368
373
  const routeKey = (route) => `${route.provider}\0${route.model}`;
369
374
 
370
- /** One well-formed {provider, model} route, or null. */
375
+ /** One well-formed {provider, model[, effort]} route, or null. */
371
376
  function sanitizeRoute(route) {
372
377
  if (route === null || typeof route !== 'object') return null;
373
378
  if (typeof route.provider !== 'string' || route.provider === '') return null;
374
379
  if (typeof route.model !== 'string' || route.model === '') return null;
375
- return { provider: route.provider, model: route.model };
380
+ // Trim side routes may carry an optional effort pin; keep it on the
381
+ // round-trip so a settings save never strips it (intent routes omit it).
382
+ const out = { provider: route.provider, model: route.model };
383
+ if (typeof route.effort === 'string' && route.effort !== '') out.effort = route.effort;
384
+ return out;
376
385
  }
377
386
 
378
387
  /** One well-formed rank entry ({provider, model[, tier]}), or null;
@@ -896,6 +905,9 @@ window.__ModuleLoader__.load({
896
905
  wmSubagentForceLabel: '子智能体强制阈值',
897
906
  wmSubagentForceHelp:
898
907
  '仅覆盖子智能体的 force 档(soft/hard 与主会话共用);0 = 跟随上方强制水位。',
908
+ wmSubagentWindowRatioLabel: '子智能体窗口比例',
909
+ wmSubagentWindowRatioHelp:
910
+ '按子智能体自身模型窗口缩放三档:force = min(静态档,窗口×比例),soft/hard 同比收缩;只收紧不放宽。',
899
911
  wmSubagentFollowForce: '跟随 force 档(0)',
900
912
  wmCommands:
901
913
  '命令:/ctx-pause 暂停水位干预 · /ctx-resume 恢复干预 · /ctx-handover 立即写交接文档并压缩。',
@@ -912,6 +924,7 @@ window.__ModuleLoader__.load({
912
924
  trimSideModelLabel: '摘要模型',
913
925
  trimSideModelHelp: '折叠摘要使用的模型路由;默认「跟随会话模型」。阶梯蒸馏同样使用该路由。',
914
926
  trimFollowSession: '跟随会话模型',
927
+ trimEffortPinLow: '默认(pin low)',
915
928
  trimFoldFallbackModelLabel: '折叠异常兜底模型',
916
929
  trimFoldFallbackModelHelp: '摘要模型调用失败时改用该模型重试;仍失败则压力折叠退回机械折叠。尽量选择没有冷却、风控或并发限制的模型,避免与主会话共用同一账号。留空 = 无兜底。',
917
930
  trimFallbackUnset: '未设置(失败时直接机械折叠)',
@@ -1160,6 +1173,9 @@ window.__ModuleLoader__.load({
1160
1173
  wmSubagentForceLabel: 'Subagent force threshold',
1161
1174
  wmSubagentForceHelp:
1162
1175
  'Overrides only the subagent force tier (soft/hard are shared with the main session); 0 follows the force watermark above.',
1176
+ wmSubagentWindowRatioLabel: 'Subagent window ratio',
1177
+ wmSubagentWindowRatioHelp:
1178
+ 'Scale the subagent tiers to its own model window: force = min(static tier, window × ratio); soft/hard shrink by the same factor. Tightens only, never loosens.',
1163
1179
  wmSubagentFollowForce: 'Follow the force tier (0)',
1164
1180
  wmCommands:
1165
1181
  'Commands: /ctx-pause suspends watermark actions · /ctx-resume resumes them · /ctx-handover writes the handover doc and compacts now.',
@@ -1179,6 +1195,7 @@ window.__ModuleLoader__.load({
1179
1195
  trimSideModelHelp:
1180
1196
  'Route for fold summaries; defaults to following the session model. Also used by ladder distillations.',
1181
1197
  trimFollowSession: 'Follow session model',
1198
+ trimEffortPinLow: 'Default (pin low)',
1182
1199
  trimFoldFallbackModelLabel: 'Fold fallback model',
1183
1200
  trimFoldFallbackModelHelp:
1184
1201
  'When the summary route fails, the fold retries on this model before degrading a pressure fold to a mechanical one. Prefer a model with headroom on cooldowns, risk control, and concurrency limits — avoid sharing the pinned account with the main session. Empty = no fallback leg.',
@@ -1434,6 +1451,9 @@ window.__ModuleLoader__.load({
1434
1451
  wmSubagentCapHelp: '開啟後子智能體可用下方獨立強制閾值;關閉則完全跟隨上方三檔水位。',
1435
1452
  wmSubagentForceLabel: '子智能體強制閾值',
1436
1453
  wmSubagentForceHelp: '僅覆蓋子智能體的 force 檔(soft/hard 與主會話共用);0 = 跟隨上方強制水位。',
1454
+ wmSubagentWindowRatioLabel: '子代理視窗比例',
1455
+ wmSubagentWindowRatioHelp:
1456
+ '按子代理自身模型視窗縮放三檔:force = min(靜態檔,視窗×比例),soft/hard 同比收縮;只收緊不放寬。',
1437
1457
  wmSubagentFollowForce: '跟隨 force 檔(0)',
1438
1458
  wmCommands: '命令:/ctx-pause 暫停水位干預 · /ctx-resume 恢復干預 · /ctx-handover 立即寫交接文件並壓縮。',
1439
1459
  trimSectionTitle: '上下文裁剪',
@@ -1449,6 +1469,7 @@ window.__ModuleLoader__.load({
1449
1469
  trimSideModelLabel: '摘要模型',
1450
1470
  trimSideModelHelp: '摺疊摘要使用的模型路由;預設「跟隨對話模型」。階梯蒸餾同樣使用該路由。',
1451
1471
  trimFollowSession: '跟隨對話模型',
1472
+ trimEffortPinLow: '預設(pin low)',
1452
1473
  trimFoldFallbackModelLabel: '摺疊異常備援模型',
1453
1474
  trimFoldFallbackModelHelp: '摘要模型呼叫失敗時改用該模型重試;仍失敗則壓力摺疊退回機械摺疊。盡量選擇沒有冷卻、風控或並行限制的模型,避免與主對話共用同一帳號。留空 = 無備援。',
1454
1475
  trimFallbackUnset: '未設定(失敗時直接機械摺疊)',
@@ -1677,6 +1698,9 @@ window.__ModuleLoader__.load({
1677
1698
  wmSubagentCapHelp: 'オンにすると、下の専用強制しきい値を使えます。オフなら上の 3 段階の水位に完全に従います。',
1678
1699
  wmSubagentForceLabel: 'サブエージェント強制しきい値',
1679
1700
  wmSubagentForceHelp: 'サブエージェントの force 段階のみを上書きします(soft/hard はメインセッションと共通)。0 なら上の強制水位に従います。',
1701
+ wmSubagentWindowRatioLabel: 'サブエージェント窓比率',
1702
+ wmSubagentWindowRatioHelp:
1703
+ 'サブエージェント自身のモデル窓に合わせて3段階を縮小:force = min(静的しきい値,窓×比率)。soft/hardも同率で縮小。引き締めのみ。',
1680
1704
  wmSubagentFollowForce: 'force 段階に従う(0)',
1681
1705
  wmCommands: 'コマンド:/ctx-pause で水位アクションを一時停止 · /ctx-resume で再開 · /ctx-handover で今すぐ引き継ぎ文書を書いて圧縮。',
1682
1706
  trimSectionTitle: 'コンテキストトリム',
@@ -1771,6 +1795,7 @@ window.__ModuleLoader__.load({
1771
1795
  '分類失敗時:前回ルート → フォールバック → 候補の先頭。空欄は候補の先頭。',
1772
1796
  intentRouteUnset: '未設定',
1773
1797
  intentEffortFollow: 'モデルのデフォルト',
1798
+ trimEffortPinLow: 'デフォルト(pin low)',
1774
1799
  intentDockAuto: '自動',
1775
1800
  intentDockUnrouted: '未ルーティング',
1776
1801
  intentDockUsing: '使用中',
@@ -1904,8 +1929,12 @@ window.__ModuleLoader__.load({
1904
1929
  wmSubagentCapHelp: '켜면 서브에이전트가 아래 전용 강제 임계값을 사용합니다. 끄면 위 세 수위를 그대로 따릅니다.',
1905
1930
  wmSubagentForceLabel: '서브에이전트 강제 임계값',
1906
1931
  wmSubagentForceHelp: '서브에이전트의 force 단계만 덮어씁니다(soft/hard는 메인 세션과 공유). 0이면 위 강제 수위를 따릅니다.',
1932
+ wmSubagentWindowRatioLabel: '서브에이전트 창 비율',
1933
+ wmSubagentWindowRatioHelp:
1934
+ '서브에이전트 자체 모델 창에 맞춰 3단계 축소: force = min(정적 기준, 창×비율). soft/hard도 같은 비율로 축소. 완화 없이 강화만.',
1907
1935
  wmSubagentFollowForce: 'force 단계 따르기(0)',
1908
1936
  wmCommands: '명령: /ctx-pause 수위 개입 일시 중지 · /ctx-resume 재개 · /ctx-handover 지금 핸드오버 문서를 작성하고 압축.',
1937
+ trimEffortPinLow: '기본 (low 고정)',
1909
1938
  trimSectionTitle: '컨텍스트 트림',
1910
1939
  trimModeLabel: '트림 모드',
1911
1940
  trimModeHelp:
@@ -2130,8 +2159,12 @@ window.__ModuleLoader__.load({
2130
2159
  wmSubagentCapHelp: 'Subagents dürfen die eigene erzwungene Schwelle unten nutzen; aus = vollständig den drei Stufen oben folgen.',
2131
2160
  wmSubagentForceLabel: 'Erzwungene Schwelle für Subagents',
2132
2161
  wmSubagentForceHelp: 'Überschreibt nur die Force-Stufe der Subagents (soft/hard teilen sich mit der Haupt-Session); 0 folgt dem erzwungenen Wasserstand oben.',
2162
+ wmSubagentWindowRatioLabel: 'Subagent-Fensterquote',
2163
+ wmSubagentWindowRatioHelp:
2164
+ 'Skaliert die Stufen auf das eigene Modellfenster des Subagenten: force = min(statische Stufe, Fenster × Quote); soft/hard schrumpfen im selben Faktor. Verengt nur, lockert nie.',
2133
2165
  wmSubagentFollowForce: 'Der Force-Stufe folgen (0)',
2134
2166
  wmCommands: 'Befehle: /ctx-pause pausiert Wasserstand-Aktionen · /ctx-resume setzt sie fort · /ctx-handover schreibt das Übergabedokument jetzt und komprimiert sofort.',
2167
+ trimEffortPinLow: 'Standard (pin low)',
2135
2168
  trimSectionTitle: 'Kontext-Trim',
2136
2169
  trimModeLabel: 'Trim-Modus',
2137
2170
  trimModeHelp:
@@ -2359,8 +2392,12 @@ window.__ModuleLoader__.load({
2359
2392
  wmSubagentCapHelp: 'Permite que los subagentes usen el umbral forzado propio de abajo; desactivado = seguir por completo los tres niveles de arriba.',
2360
2393
  wmSubagentForceLabel: 'Umbral forzado de subagentes',
2361
2394
  wmSubagentForceHelp: 'Solo sobrescribe el nivel forzado de los subagentes (soft/hard se comparten con la sesión principal); 0 sigue el nivel forzado de arriba.',
2395
+ wmSubagentWindowRatioLabel: 'Ratio de ventana del subagente',
2396
+ wmSubagentWindowRatioHelp:
2397
+ 'Escala los niveles a la ventana del propio modelo del subagente: force = min(nivel estático, ventana × ratio); soft/hard se reducen por el mismo factor. Solo restringe, nunca amplía.',
2362
2398
  wmSubagentFollowForce: 'Seguir el nivel forzado (0)',
2363
2399
  wmCommands: 'Comandos: /ctx-pause suspende las acciones del nivel · /ctx-resume las reanuda · /ctx-handover escribe el documento y compacta ahora.',
2400
+ trimEffortPinLow: 'Predeterminado (pin low)',
2364
2401
  trimSectionTitle: 'Recorte de contexto',
2365
2402
  trimModeLabel: 'Modo de recorte',
2366
2403
  trimModeHelp:
@@ -2589,8 +2626,12 @@ window.__ModuleLoader__.load({
2589
2626
  wmSubagentCapHelp: 'Laisse les subagents utiliser le seuil force dédié ci-dessous ; désactivé = suivent intégralement les trois paliers ci-dessus.',
2590
2627
  wmSubagentForceLabel: 'Seuil force des subagents',
2591
2628
  wmSubagentForceHelp: 'Ne remplace que le palier force des subagents (soft/hard partagés avec la session principale) ; 0 suit la ligne d’eau force ci-dessus.',
2629
+ wmSubagentWindowRatioLabel: 'Ratio de fenêtre du sous-agent',
2630
+ wmSubagentWindowRatioHelp:
2631
+ 'Ajuste les paliers à la fenêtre propre du modèle du sous-agent : force = min(palier statique, fenêtre × ratio) ; soft/hard réduits du même facteur. Ne fait que resserrer, jamais relâcher.',
2592
2632
  wmSubagentFollowForce: 'Suivre le palier force (0)',
2593
2633
  wmCommands: 'Commandes : /ctx-pause suspend les actions de niveau d’eau · /ctx-resume les reprend · /ctx-handover écrit le document et compacte maintenant.',
2634
+ trimEffortPinLow: 'Par défaut (pin low)',
2594
2635
  trimSectionTitle: 'Rognage du contexte',
2595
2636
  trimModeLabel: 'Mode de rognage',
2596
2637
  trimModeHelp:
@@ -2819,8 +2860,12 @@ window.__ModuleLoader__.load({
2819
2860
  wmSubagentCapHelp: 'Lascia i subagent usare la soglia force dedicata qui sotto; spento = seguono appieno le tre soglie sopra.',
2820
2861
  wmSubagentForceLabel: 'Soglia force dei subagent',
2821
2862
  wmSubagentForceHelp: 'Sostituisce solo la soglia force dei subagent (soft/hard restano condivise con la sessione principale); 0 segue la soglia force sopra.',
2863
+ wmSubagentWindowRatioLabel: 'Rapporto finestra del subagente',
2864
+ wmSubagentWindowRatioHelp:
2865
+ 'Ridimensiona i livelli alla finestra del modello del subagente: force = min(livello statico, finestra × rapporto); soft/hard si riducono dello stesso fattore. Solo restrizione, mai allentamento.',
2822
2866
  wmSubagentFollowForce: 'Segui la soglia force (0)',
2823
2867
  wmCommands: 'Comandi: /ctx-pause sospende le azioni da livello dell’acqua · /ctx-resume le riprende · /ctx-handover scrive il documento e compatta subito.',
2868
+ trimEffortPinLow: 'Predefinito (pin low)',
2824
2869
  trimSectionTitle: 'Riduzione del contesto',
2825
2870
  trimModeLabel: 'Modalità di riduzione',
2826
2871
  trimModeHelp:
@@ -3049,8 +3094,12 @@ window.__ModuleLoader__.load({
3049
3094
  wmSubagentCapHelp: 'Deixa os subagents usar o limiar force dedicado abaixo; desligado = seguem integralmente os três limiares acima.',
3050
3095
  wmSubagentForceLabel: 'Limiar force dos subagents',
3051
3096
  wmSubagentForceHelp: 'Sobrepõe apenas o limiar force dos subagents (soft/hard são compartilhados com a sessão principal); 0 segue o limiar force acima.',
3097
+ wmSubagentWindowRatioLabel: 'Razão de janela do subagente',
3098
+ wmSubagentWindowRatioHelp:
3099
+ 'Escala os níveis para a janela do próprio modelo do subagente: force = min(nível estático, janela × razão); soft/hard encolhem pelo mesmo fator. Apenas aperta, nunca afrouxa.',
3052
3100
  wmSubagentFollowForce: 'Seguir o limiar force (0)',
3053
3101
  wmCommands: 'Comandos: /ctx-pause suspende as ações de nível de água · /ctx-resume as retoma · /ctx-handover escreve o documento e compacta agora.',
3102
+ trimEffortPinLow: 'Padrão (pin low)',
3054
3103
  trimSectionTitle: 'Corte de contexto',
3055
3104
  trimModeLabel: 'Modo de corte',
3056
3105
  trimModeHelp:
@@ -3279,8 +3328,12 @@ window.__ModuleLoader__.load({
3279
3328
  wmSubagentCapHelp: 'Субагенты используют отдельный порог force ниже; выключено = полностью следуют трём линиям выше.',
3280
3329
  wmSubagentForceLabel: 'Порог force субагентов',
3281
3330
  wmSubagentForceHelp: 'Меняет только линию force субагентов (soft/hard общие с главной сессией); 0 — следовать линии force выше.',
3331
+ wmSubagentWindowRatioLabel: 'Доля окна субагента',
3332
+ wmSubagentWindowRatioHelp:
3333
+ 'Масштабирует уровни под собственное окно модели субагента: force = min(статический уровень, окно × доля); soft/hard сжимаются на тот же коэффициент. Только ужесточает, не смягчает.',
3282
3334
  wmSubagentFollowForce: 'Следовать линии force (0)',
3283
3335
  wmCommands: 'Команды: /ctx-pause приостанавливает действия по уровню воды · /ctx-resume возобновляет их · /ctx-handover записывает документ передачи и сжимает прямо сейчас.',
3336
+ trimEffortPinLow: 'По умолчанию (pin low)',
3284
3337
  trimSectionTitle: 'Обрезка контекста',
3285
3338
  trimModeLabel: 'Режим обрезки',
3286
3339
  trimModeHelp:
@@ -5856,6 +5909,7 @@ window.__ModuleLoader__.load({
5856
5909
  (meta) => meta.model === route.model,
5857
5910
  );
5858
5911
  edit(field, {
5912
+ ...route,
5859
5913
  provider: next,
5860
5914
  model: kept ? route.model : '',
5861
5915
  });
@@ -5880,18 +5934,31 @@ window.__ModuleLoader__.load({
5880
5934
  disabled: !editable || route.provider === '',
5881
5935
  'aria-label': `${label} · model`,
5882
5936
  }, ...trimModelOptions(followMode, route, route.model)),
5937
+ route.provider !== ''
5938
+ ? intentEffortSelect(
5939
+ `${trimId}-${field}-effort`,
5940
+ route,
5941
+ route.effort,
5942
+ (next) => edit(field, { ...route, effort: next }),
5943
+ `${label} · effort`,
5944
+ t('trimEffortPinLow'),
5945
+ )
5946
+ : null,
5883
5947
  );
5884
5948
  };
5885
5949
  /** Reasoning-effort select for one intent route: the model's
5886
5950
  * wire efforts when the catalog reports them, the EFFORTS
5887
5951
  * fallback otherwise; '' = adapter default. A stored value
5888
- * missing from the list stays selectable. */
5952
+ * missing from the list stays selectable. emptyLabel replaces
5953
+ * the '' option label (trim rows pin low instead of following
5954
+ * the model default). */
5889
5955
  const intentEffortSelect = (
5890
5956
  id,
5891
5957
  route,
5892
5958
  value,
5893
5959
  onCommit,
5894
5960
  ariaLabel,
5961
+ emptyLabel,
5895
5962
  ) => {
5896
5963
  const meta = catalogIndex.get(routeKey(route));
5897
5964
  const wire =
@@ -5915,7 +5982,7 @@ window.__ModuleLoader__.load({
5915
5982
  style: STYLE.effortSelect,
5916
5983
  'aria-label': ariaLabel,
5917
5984
  },
5918
- h('option', { value: '' }, t('intentEffortFollow')),
5985
+ h('option', { value: '' }, emptyLabel ?? t('intentEffortFollow')),
5919
5986
  ...[...wire, ...pending].map((entry) =>
5920
5987
  h('option', { value: entry.id }, entry.name ?? entry.id),
5921
5988
  ),
package/cordis.patch.yml CHANGED
@@ -633,3 +633,32 @@
633
633
  - insert:
634
634
  - id: dsh-switchman-host
635
635
  name: dsh-switchman
636
+ config:
637
+ langScope: project
638
+ dispatchEnforce: advice
639
+ wmSoftTokens: 80000
640
+ wmHardTokens: 95000
641
+ wmForceTokens: 150000
642
+ wmReadBudgetTokens: 3000
643
+ wmDenyMode: cap
644
+ wmAutoHandover: true
645
+ wmSubagentCap: true
646
+ wmSubagentForceTokens: 0
647
+ wmSubagentWindowRatio: 0.75
648
+ teamsMode: true
649
+ syncWhitelist: true
650
+ trimEnabled: true
651
+ trimMode: auto
652
+ trimGrowthTokens: 22500
653
+ trimIncrementTokens: 25000
654
+ trimPressurePercent: 65
655
+ trimEmergencyPercent: 95
656
+ trimWindowTokens: 0
657
+ trimProtectedTail: 10
658
+ trimMaxFoldsPerHour: 30
659
+ trimLadderEnabled: true
660
+ trimLadderT2Tokens: 8000
661
+ trimLadderT2MinBlocks: 3
662
+ trimLadderT3Tokens: 6000
663
+ trimLadderT3MinBlocks: 2
664
+ surfaceFold: true
package/host/config.js CHANGED
@@ -67,10 +67,14 @@ export const IntentCandidateSchema = z.object({
67
67
  * ModelRouteSchema this accepts the "unset" `{provider:"", model:""}`
68
68
  * pair the settings page round-trips — ModelRouteSchema's `.min(1)`
69
69
  * would reject that exact declared default on every settings write.
70
- * Consumers validate non-empty before trusting the route. */
70
+ * Consumers validate non-empty before trusting the route. effort follows
71
+ * the optional no-`.required()` style (schemastery passes undefined
72
+ * through): the trim summarizer reads it as a reasoning-effort pin;
73
+ * intent-layer routes leave it unset. */
71
74
  const IntentRouteSchema = z.object({
72
75
  provider: z.string(),
73
76
  model: z.string(),
77
+ effort: z.string(),
74
78
  });
75
79
 
76
80
  /** All dsh-switchman settings. Field names are the settings mutation paths. */
@@ -153,6 +157,14 @@ export const Config = z.object({
153
157
  wmSubagentCap: z.boolean().default(true).volatile(),
154
158
  /** Subagent hard cap; 0 = share wmForceTokens. */
155
159
  wmSubagentForceTokens: z.number().min(0).default(0).volatile(),
160
+ /** Scale subagent watermark tiers to the subagent's own model window:
161
+ * force = min(static force [or wmSubagentForceTokens], window × ratio);
162
+ * soft/hard shrink by the same factor to preserve tier spacing. The ratio
163
+ * can only TIGHTEN tiers, never loosen them; 1 = cap at the full model
164
+ * window (still tightens when the window is smaller than the static
165
+ * force tier). Resolution is async and cached per model route; until it
166
+ * lands, the static tiers apply (fail-open). */
167
+ wmSubagentWindowRatio: z.number().min(0.1).max(1).default(0.75).volatile(),
156
168
 
157
169
  // --- Teams mode: doctrine injection + whitelist sync -------------------
158
170
  /** Agent Teams master switch. ON = inject the `[SWITCHMAN:TEAMS]` doctrine
@@ -58,6 +58,7 @@ import { childSessionIdOf } from "../subagent-model.js";
58
58
  // it and the stored tally takes over.
59
59
  const trimViewBySession = new Map(); // sessionId -> { viewTokens, at, inject, lastNoneAt }
60
60
  const trimWireBySession = new Map(); // sessionId -> { promptTokens, at } — last sampled provider prompt of an accepted MAIN dispatch (side calls never write; a tiny inherited-id usage sample must not lower the watermark's wire floor)
61
+ const capturedModelBySession = new Map(); // sessionId -> { provider, model, at } — last stream-captured model route, read by the watermark layer to scale subagent tiers to the session's own model window
61
62
  /** Freshness TTL for the in-memory view/injection registry (watermark and
62
63
  * banner display only). NEVER a fold gate: gating folds on "did the surface
63
64
  * channel ship recently" deadlocks resumed sessions (2026-11-05). The fold
@@ -112,6 +113,19 @@ export function recentTrimViewTokensOf(sessionId, maxAgeMs = TRIM_VIEW_MAX_AGE_M
112
113
  }
113
114
  }
114
115
 
116
+ /** Most recent captured model route ({provider, model}) for a session, or
117
+ * null when missing, stale, or malformed. The watermark layer reads this to
118
+ * look up the session's own model window (subagent tier scaling). */
119
+ export function recentTrimModelOf(sessionId, maxAgeMs = TRIM_VIEW_MAX_AGE_MS) {
120
+ try {
121
+ const entry = capturedModelBySession.get(sessionId);
122
+ if (entry === undefined || Date.now() - entry.at > maxAgeMs) return null;
123
+ return typeof entry.provider === "string" && typeof entry.model === "string" ? { provider: entry.provider, model: entry.model } : null;
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
115
129
  /** Meter-duality bridge, wire domain (design §8): the same number the trim
116
130
  * pressure tiers evaluate in maybeFold — Math.max(outbound view estimate,
117
131
  * the last sampled provider prompt) — or null when the gate rejects or
@@ -234,7 +248,11 @@ function readRoute(config, key) {
234
248
  let got = value;
235
249
  if (value !== null && typeof value === "object" && typeof value.get === "function") got = value.get();
236
250
  if (got !== null && typeof got === "object" && typeof got.provider === "string" && got.provider !== "" && typeof got.model === "string" && got.model !== "") {
237
- return { provider: got.provider, model: got.model };
251
+ // Pass the configured effort through verbatim (IntentRouteSchema
252
+ // already carries the optional field); absent/empty just omits it.
253
+ return typeof got.effort === "string" && got.effort !== ""
254
+ ? { provider: got.provider, model: got.model, effort: got.effort }
255
+ : { provider: got.provider, model: got.model };
238
256
  }
239
257
  } catch {
240
258
  /* fall through */
@@ -576,6 +594,10 @@ export function applyContextTrim(ctx, config) {
576
594
  * copy — message objects are treated as immutable records). */
577
595
  function captureOf(sessionId, messages, model, viewTokens) {
578
596
  captures.set(sessionId, { messages: messages.slice(), model, viewTokens, at: Date.now() });
597
+ if (model !== null && typeof model === "object" && typeof model.provider === "string" && typeof model.model === "string") {
598
+ capturedModelBySession.set(sessionId, { provider: model.provider, model: model.model, at: Date.now() });
599
+ if (capturedModelBySession.size > 200) capturedModelBySession.delete(capturedModelBySession.keys().next().value);
600
+ }
579
601
  // keep the capture map bounded (200 sessions)
580
602
  if (captures.size > 200) {
581
603
  const oldest = captures.keys().next().value;
@@ -673,7 +695,15 @@ export function applyContextTrim(ctx, config) {
673
695
  } else if (view.applied > 0) {
674
696
  epochResetDoneBySession.delete(sessionId);
675
697
  }
676
- const model = { provider: options.provider ?? null, model: options.model ?? null };
698
+ // Capture the session's own resolved reasoningEffort alongside
699
+ // provider/model: at stream-listener time DSH has already resolved the
700
+ // effective effort for this dispatch, so the side route can inherit it
701
+ // verbatim instead of re-deriving (and losing) it.
702
+ const model = {
703
+ provider: options.provider ?? null,
704
+ model: options.model ?? null,
705
+ effort: typeof options.reasoningEffort === "string" && options.reasoningEffort !== "" ? options.reasoningEffort : null,
706
+ };
677
707
  captureOf(sessionId, rawSnapshot, model, viewTokens);
678
708
  metrics.noteView(sessionId, viewTokens, model);
679
709
  const priorEntry = trimViewBySession.get(sessionId);
@@ -694,7 +724,10 @@ export function applyContextTrim(ctx, config) {
694
724
  if (explicit !== null) return explicit;
695
725
  const model = metrics.of(sessionId)?.lastModel ?? null;
696
726
  if (model !== null && typeof model.provider === "string" && model.provider !== "" && typeof model.model === "string" && model.model !== "") {
697
- return { provider: model.provider, model: model.model };
727
+ // Inherit the session's own resolved effort when one was captured.
728
+ return typeof model.effort === "string" && model.effort !== ""
729
+ ? { provider: model.provider, model: model.model, effort: model.effort }
730
+ : { provider: model.provider, model: model.model };
698
731
  }
699
732
  return null;
700
733
  }
@@ -733,6 +766,7 @@ export function applyContextTrim(ctx, config) {
733
766
  const summarize = (r, tag) => summarizeWithModel(llm, r, renderLadderMaterial(consumed), {
734
767
  systemPrompt: LADDER_SYSTEM_PROMPT,
735
768
  onFail: (why) => decisionLog(`session=${sessionId} summary-fail route=${tag} why=${why} blocks=${consumed.length} ~${approxTokens}t ladder=${decision.tier}`),
769
+ onNotice: (why) => decisionLog(`session=${sessionId} summary-retry route=${tag} why=${why} blocks=${consumed.length} ~${approxTokens}t ladder=${decision.tier}`),
736
770
  });
737
771
  let summary = route !== null ? await summarize(route, "side") : null;
738
772
  if (summary === null && fallback !== null && !sameRoute(fallback, route)) summary = await summarize(fallback, "fallback");
@@ -779,6 +813,7 @@ export function applyContextTrim(ctx, config) {
779
813
  const material = renderMaterial(capture.messages, startIdx, endIdx, refOf);
780
814
  const summarize = (r, tag) => summarizeWithModel(llm, r, material, {
781
815
  onFail: (why) => decisionLog(`session=${sessionId} summary-fail route=${tag} why=${why} msgs=${count} ~${approxTokens}t`),
816
+ onNotice: (why) => decisionLog(`session=${sessionId} summary-retry route=${tag} why=${why} msgs=${count} ~${approxTokens}t`),
782
817
  });
783
818
  summary = route !== null ? await summarize(route, "side") : null;
784
819
  if (summary === null && fallback !== null && !sameRoute(fallback, route)) summary = await summarize(fallback, "fallback");
@@ -67,8 +67,7 @@ export function createMetrics() {
67
67
  cacheWriteTokens: null, // cache-write bucket of the last sample
68
68
  outputTokens: null,
69
69
  viewTokens: null, // view estimate noted by the listener pre-request
70
- lastModel: null, // { provider, model } of the last stream
71
- lastStreamAt: 0,
70
+ lastModel: null, // { provider, model, effort } of the last stream
72
71
  totalStreams: 0,
73
72
  };
74
73
  }
@@ -87,8 +86,7 @@ export function createMetrics() {
87
86
  noteView(sessionId, viewTokens, model) {
88
87
  const record = recordOf(sessionId);
89
88
  if (Number.isFinite(viewTokens)) record.viewTokens = Math.round(viewTokens);
90
- if (model !== null && typeof model === "object") record.lastModel = { provider: model.provider ?? null, model: model.model ?? null };
91
- record.lastStreamAt = Date.now();
89
+ if (model !== null && typeof model === "object") record.lastModel = { provider: model.provider ?? null, model: model.model ?? null, effort: typeof model.effort === "string" && model.effort !== "" ? model.effort : null };
92
90
  record.totalStreams += 1;
93
91
  return record;
94
92
  },
@@ -225,13 +225,15 @@ function describeFailure(chunk) {
225
225
  }
226
226
 
227
227
  /** Side-model summary. llm: the dsh llm service (receiver-bound methods);
228
- * route: { provider, model } | null (null → caller skips); materialText:
229
- * renderMaterial output; options.systemPrompt overrides the default
228
+ * route: { provider, model, effort? } | null (null → caller skips); materialText:
230
229
  * compression prompt (the ladder distillation passes its own); deadlineMs:
231
230
  * hard budget. Returns the summary text, or null on ANY failure (caller
232
231
  * skips the fold cycle). */
233
232
  export async function summarizeWithModel(llm, route, materialText, options = {}) {
234
233
  const fail = typeof options.onFail === "function" ? options.onFail : null;
234
+ // Effort-retry notice: fires BEFORE the low-pinned retry, whether or not
235
+ // the retry lands. Falls back to fail when the caller doesn't supply one.
236
+ const notice = typeof options.onNotice === "function" ? options.onNotice : null;
235
237
  if (route === null || route === undefined) { fail?.("no-route"); return null; }
236
238
  if (typeof llm?.prepareCall !== "function") { fail?.("no-prepare-call"); return null; }
237
239
  const deadlineMs = Number.isFinite(options.deadlineMs) && options.deadlineMs > 0 ? options.deadlineMs : SUMMARY_TIMEOUT_MS;
@@ -240,11 +242,40 @@ export async function summarizeWithModel(llm, route, materialText, options = {})
240
242
  const controller = new AbortController();
241
243
  const timer = setTimeout(() => controller.abort(new Error("trim summary deadline")), deadlineMs);
242
244
  try {
243
- const call = await llm.prepareCall(
244
- { provider: route.provider, model: route.model, maxTokens },
245
- controller.signal,
246
- (controls) => controls,
247
- );
245
+ // Effort policy: inherit the route's effort verbatim when it carries
246
+ // one (session-inherited routes carry the session's own DSH-resolved
247
+ // effort; explicit trimSideModel configs may pin theirs), else pin
248
+ // "low" — summaries are auxiliary work. NEVER send without an effort,
249
+ // and never send "off": pi-ai maps both to thinking:{type:"disabled"}
250
+ // (profileOptions turns "off" back into an absent reasoning option),
251
+ // which newer Anthropic endpoints reject with 400. A DSH session run
252
+ // with thinking disabled resolves its effort to "off", so an inherited
253
+ // route can carry exactly that value — treat it like empty here.
254
+ // The effort rides the prepareCall proposal so it resolves into
255
+ // call.config, keeping stream options consistent with the prepared
256
+ // call (INVALID_PREPARED_CALL guard).
257
+ const requestedEffort = typeof route.effort === "string" && route.effort !== "" && route.effort !== "off" ? route.effort : "low";
258
+ let call;
259
+ try {
260
+ call = await llm.prepareCall(
261
+ { provider: route.provider, model: route.model, maxTokens, reasoningEffort: requestedEffort },
262
+ controller.signal,
263
+ (controls) => controls,
264
+ );
265
+ } catch (prepareError) {
266
+ // An inherited session effort can exceed what the route's adapter
267
+ // supports: retry once pinned low before giving up (the outer
268
+ // catch then turns the failure into a skipped fold cycle).
269
+ const code = prepareError?.code ?? null;
270
+ const named = typeof prepareError?.message === "string" && prepareError.message.includes("UNSUPPORTED_REASONING_EFFORT");
271
+ if (requestedEffort === "low" || (code !== "UNSUPPORTED_REASONING_EFFORT" && !named)) throw prepareError;
272
+ (notice ?? fail)?.(`effort "${requestedEffort}" unsupported, retrying low`);
273
+ call = await llm.prepareCall(
274
+ { provider: route.provider, model: route.model, maxTokens, reasoningEffort: "low" },
275
+ controller.signal,
276
+ (controls) => controls,
277
+ );
278
+ }
248
279
  let text = "";
249
280
  let finishKind = null;
250
281
  // stream options MUST carry the prepared call config — the adapter
@@ -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 { recentTrimPromptTokensOf } from "./context-trim/index.js";
56
+ import { recentTrimModelOf, 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"]);
@@ -241,6 +241,69 @@ function renderSubWatermark(level) {
241
241
  );
242
242
  }
243
243
 
244
+ /** Freshness TTL for resolved subagent model windows (same cadence as the
245
+ * trim engine's windowCache: 10 minutes, negative results cached too). */
246
+ const SUB_WINDOW_TTL_MS = 600_000;
247
+
248
+ /** Synchronously read the resolved context window (tokens) of the model a
249
+ * subagent session is running on, or null when unknown/stale. On a cache
250
+ * miss it primes the resolution in the background and returns null — the
251
+ * first render falls back to the static tiers (fail-open), later renders
252
+ * see the scaled tiers. Resolution goes through ctx.get("llm").
253
+ * resolveModelInfo, the same DSH channel the trim engine uses. */
254
+ function subagentWindowTokens(ctx, state, sessionId) {
255
+ try {
256
+ const route = recentTrimModelOf(sessionId);
257
+ if (route === null) return null;
258
+ const key = `${route.provider}/${route.model}`;
259
+ const now = Date.now();
260
+ const entry = state.subWindow.get(key);
261
+ if (entry !== undefined && now - entry.at < SUB_WINDOW_TTL_MS) return entry.value;
262
+ if (entry === undefined || entry.priming !== true) {
263
+ // Occupy the slot so concurrent renders do not fan out resolutions;
264
+ // at=0 keeps the read path treating it as a miss until primed.
265
+ state.subWindow.set(key, { value: null, at: 0, priming: true });
266
+ void primeSubagentWindow(ctx, state, route, key);
267
+ }
268
+ return null;
269
+ } catch {
270
+ return null;
271
+ }
272
+ }
273
+
274
+ /** Async window resolution backing subagentWindowTokens; stores the raw
275
+ * resolved window (null when unknown — negative-cached for the same TTL). */
276
+ async function primeSubagentWindow(ctx, state, route, key) {
277
+ try {
278
+ const llm = ctx.get?.("llm", false);
279
+ const info = await llm?.resolveModelInfo?.({ provider: route.provider, model: route.model });
280
+ const windowTokens = info?.context?.contextWindow;
281
+ const value = Number.isFinite(windowTokens) && windowTokens > 0 ? windowTokens : null;
282
+ state.subWindow.set(key, { value, at: Date.now() });
283
+ if (state.subWindow.size > 200) state.subWindow.delete(state.subWindow.keys().next().value);
284
+ } catch {
285
+ state.subWindow.set(key, { value: null, at: Date.now() });
286
+ }
287
+ }
288
+
289
+ /** Pure subagent tier scaling (exported for tests): cap force at
290
+ * windowTokens × ratio and shrink soft/hard by the same factor (floored at
291
+ * 1000/2000) so tier spacing and ordering survive the shrink. Tighten-only:
292
+ * a null/non-positive window, a cap ≥ the input force, or a cap below 3000
293
+ * returns the tiers unchanged (fail-open). */
294
+ export function scaleSubagentTiers(th, windowTokens, ratio) {
295
+ const subTh = { ...th };
296
+ if (!Number.isFinite(windowTokens) || windowTokens <= 0) return subTh;
297
+ const cap = Math.floor(windowTokens * ratio);
298
+ if (cap < subTh.force && cap >= 3000) {
299
+ const factor = cap / subTh.force;
300
+ subTh.soft = Math.max(1000, Math.floor(subTh.soft * factor));
301
+ subTh.hard = Math.max(2000, Math.floor(subTh.hard * factor));
302
+ subTh.force = cap;
303
+ }
304
+ return subTh;
305
+ }
306
+
244
307
  /** Whole live line for one assembly (the tail context); "" whenever anything
245
308
  * needed is missing or the session sits at the silent ok tier. */
246
309
  function renderWatermarkContext(ctx, config, state, agent) {
@@ -250,16 +313,25 @@ function renderWatermarkContext(ctx, config, state, agent) {
250
313
  const th = readThresholds(config);
251
314
  const used = measureNow(ctx, agent);
252
315
  if (used === null) return "";
253
- const level = levelOf(used, th);
254
- if (level === "ok") return "";
255
316
  if (!isRoot(ctx, agent)) {
256
- // Subagents share the root soft/hard thresholds; the optional
257
- // wmSubagentForceTokens override replaces only the force tier
258
- // (enabled by the wmSubagentCap toggle, 0/0 = follow the root set).
317
+ // Subagent tiers: root baseline, optional static force override, then
318
+ // capped by the subagent's OWN model window (ratio × window), so a
319
+ // small-window child model gets proportionally earlier tiers instead
320
+ // of inheriting tiers sized for the root model. Scaling can only
321
+ // TIGHTEN (min), never loosen; soft/hard shrink by the same factor to
322
+ // preserve tier spacing. Window resolution is async, so the render
323
+ // path reads a primed cache and primes in the background on miss —
324
+ // first renders fall back to the static tiers (fail-open).
259
325
  const subForce = readBool(config.wmSubagentCap, true) ? readNumber(config.wmSubagentForceTokens, 0) : 0;
260
- const subTh = subForce > 0 ? { ...th, force: subForce } : th;
326
+ const subTh = scaleSubagentTiers(
327
+ subForce > 0 ? { ...th, force: subForce } : { ...th },
328
+ subagentWindowTokens(ctx, state, sessionId),
329
+ readNumber(config.wmSubagentWindowRatio, 0.75),
330
+ );
261
331
  return renderSubWatermark(levelOf(used, subTh));
262
332
  }
333
+ const level = levelOf(used, th);
334
+ if (level === "ok") return "";
263
335
  if (state.paused.has(sessionId)) return PAUSED_LINE;
264
336
  const live = handoverOf(sessionId);
265
337
  const elapsed = Date.now() - (state.lastHandover.get(sessionId) ?? 0);
@@ -333,6 +405,7 @@ function applyContextWatch(ctx, config) {
333
405
  handoverTool: null,
334
406
  lastHandover: new Map(),
335
407
  readUsed: new Map(),
408
+ subWindow: new Map(), // "provider/model" -> { value: number|null, at } — resolved subagent model windows (see subagentWindowTokens)
336
409
  };
337
410
  const warn = (message) => ctx.logger?.warn?.(`dsh-switchman: ${message}`);
338
411