dsh-auto-model-routing 0.5.2 → 0.5.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.4
4
+
5
+ - GPT Sol (`gpt-6.1-sol`) now starts at `high` instead of `medium`, one step above the matching Claude start, so its lower/baseline/higher efforts become `medium/high/xhigh` for both the main session and Sol child routes. Luna and Astra are unchanged; automatic routing still never uses `max`. This follows user-reported quality concerns, not a controlled evaluation. Existing bound child routes retain their recorded effort.
6
+
7
+ ## 0.5.3
8
+
9
+ - Replace both README demo GIFs with representative PNG screenshots (an applied AUTO decision with its distribution; decision service settings with a connection test), because the DSH plugin store does not show GIFs by default. No runtime behavior changes.
10
+
3
11
  ## 0.5.2
4
12
 
5
13
  - README follows the standard plugin layout: demos side by side, then the architecture diagram, then the introduction; a closing section links the ohmydsh source, the MIT license and the issue tracker.
package/README.md CHANGED
@@ -3,11 +3,11 @@
3
3
  English · [简体中文](README.zh.md)
4
4
 
5
5
  <p align="center">
6
- <img src="docs/auto-routing.gif" alt="AUTO: switch modes, apply effort and inspect the decision distribution" width="49%">
7
- <img src="docs/settings.gif" alt="Settings: decision service, connection test, defaults and strategy details" width="49%">
6
+ <img src="docs/auto-routing.png" alt="AUTO applied a higher effort to the main session; the panel shows the returned decision distribution" width="49%">
7
+ <img src="docs/settings.png" alt="Settings: decision service, compatible endpoint and a successful connection test" width="49%">
8
8
  </p>
9
9
 
10
- *Left: AUTO interaction and applied decision. Right: current settings and strategy details. Recorded from an isolated DSH host with local mock Jev/model responses and synthetic tasks.*
10
+ *Left: an applied AUTO decision with its effort distribution. Right: decision service settings with a successful connection test. Captured from an isolated DSH host with local mock Jev/model responses and synthetic tasks.*
11
11
 
12
12
  ![AUTO architecture — independent judgments, validated requests and allocation feedback](docs/architecture-v2.en.svg)
13
13
 
package/README.zh.md CHANGED
@@ -3,11 +3,11 @@
3
3
  [English](README.md) · 简体中文
4
4
 
5
5
  <p align="center">
6
- <img src="docs/auto-routing.gif" alt="AUTO:切换模式、应用推理投入并查看决策分布" width="49%">
7
- <img src="docs/settings.gif" alt="设置:决策服务、连接测试、默认值与策略说明" width="49%">
6
+ <img src="docs/auto-routing.png" alt="AUTO 已为主会话应用更高推理投入,面板显示返回的决策分布" width="49%">
7
+ <img src="docs/settings.png" alt="设置:决策服务、兼容端点与连接测试成功" width="49%">
8
8
  </p>
9
9
 
10
- *左:AUTO 交互与已应用的决策;右:当前设置页与策略说明。录制来自隔离 DSH 宿主,使用本地模拟 Jev/模型响应与合成任务。*
10
+ *左:已应用的 AUTO 决策与投入分布;右:决策服务设置与连接测试成功。截图来自隔离 DSH 宿主,使用本地模拟 Jev/模型响应与合成任务。*
11
11
 
12
12
  ![AUTO 架构:独立判断、请求校验与分配反馈](docs/architecture-v2.zh.svg)
13
13
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  | file | kind | source | date | reviewer | verdict | note |
4
4
  | --- | --- | --- | --- | --- | --- | --- |
5
- | auto-routing.gif | screenshot | isolated DSH_HOME under /tmp, port 3199; current plugin UI with local loopback mock Jev and model streaming responses; Playwright/CDP lossless 2x screenshots, global-palette GIF | 2026-10-28 | lead | pass (reviewed source frames: synthetic task/reply, complete conversation and decision panel; no real credentials or user data) | 1840x1600, 207702 bytes; MANUAL → AUTO, same-model medium → high, applied decision with returned distribution, back to MANUAL |
6
- | settings.gif | screenshot | same isolated host; lossless 2x screenshots box-filtered to 1137x1070 | 2026-10-28 | lead | pass (reviewed source frames: full settings dialog, empty key input, synthetic loopback endpoint; no real credentials) | 385416 bytes; decision service menu, successful mock connection test, default mode, effort cap, save and expand current strategy details; replaces the retired four-tier demo |
5
+ | auto-routing.png | screenshot | frame 5 of the 2026-10-28 recording (isolated DSH_HOME under /tmp, port 3199; current plugin UI with local loopback mock Jev and model streaming responses; Playwright/CDP lossless 2x screenshots), exported losslessly from the global-palette GIF | 2026-10-29 | lead | pass (reviewed frame: synthetic task/reply and decision panel; no real credentials or user data) | 1840x1600, 245191 bytes; applied same-model medium → high decision with the returned effort distribution; replaces auto-routing.gif because the plugin store does not show GIFs by default |
6
+ | settings.png | screenshot | frame 3 of the same 2026-10-28 recording (lossless 2x screenshots box-filtered to 1137x1070), exported losslessly from the GIF | 2026-10-29 | lead | pass (reviewed frame: settings dialog, empty key input, synthetic loopback endpoint; no real credentials) | 198719 bytes; decision service, decision model, compatible endpoint and a successful mock connection test; replaces settings.gif |
Binary file
Binary file
@@ -7,6 +7,12 @@ interface AutoSupport {
7
7
  model?: string;
8
8
  }
9
9
  interface View {
10
+ childNotice?: {
11
+ id: number;
12
+ childId: string;
13
+ reason: string;
14
+ at: number;
15
+ };
10
16
  mode: 'AUTO' | 'MANUAL';
11
17
  depth?: string;
12
18
  effort?: string;
@@ -38,6 +44,6 @@ export declare function RoutingChip({ sessionId, rpc, initialView, initiallyOpen
38
44
  onMouseLeave: () => void;
39
45
  onFocus: () => void;
40
46
  onBlur: () => void;
41
- }, HTMLSpanElement>;
47
+ }, HTMLSpanElement> | null;
42
48
  export declare function apply(ctx: Context): void;
43
49
  export {};
@@ -39,11 +39,13 @@ const CSS = `
39
39
  function SwapIcon() {
40
40
  return h('svg', { className: 'amr-icon amr-swap', width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true }, h('path', { d: 'M3 5.5h9.5M10 3l2.5 2.5L10 8' }), h('path', { d: 'M13 10.5H3.5M6 8l-2.5 2.5L6 13' }));
41
41
  }
42
+ const noticeText = (notice) => `最近子任务未路由:${notice.reason}(按 DSH 默认运行)`;
42
43
  export function RoutingChip({ sessionId, rpc, initialView, initiallyOpen = false, initialToast, subscribeSelection }) {
43
44
  const [view, setView] = useState(initialView);
44
45
  const [open, setOpen] = useState(initiallyOpen);
45
46
  const [busy, setBusy] = useState(false);
46
47
  const [error, setError] = useState('');
48
+ const [notApplicable, setNotApplicable] = useState(false);
47
49
  const timer = useRef(undefined);
48
50
  const [toast, setToast] = useState(initialToast);
49
51
  // The composer hides the right-side controls while the agent runs (when Jev decides), so the hint is
@@ -79,6 +81,25 @@ export function RoutingChip({ sessionId, rpc, initialView, initiallyOpen = false
79
81
  remember();
80
82
  setToast(next);
81
83
  }, [view, sessionId]);
84
+ const seenNotice = useRef(undefined);
85
+ const noticePrimed = useRef(false);
86
+ useEffect(() => {
87
+ if (!view || viewSession.current !== sessionId)
88
+ return;
89
+ const notice = view.childNotice;
90
+ if (!noticePrimed.current) {
91
+ noticePrimed.current = true;
92
+ seenNotice.current = notice?.id;
93
+ return;
94
+ }
95
+ if (!notice || notice.id === seenNotice.current)
96
+ return;
97
+ seenNotice.current = notice.id;
98
+ if (!view.decisionToast)
99
+ return;
100
+ remember();
101
+ setToast({ id: -notice.id, lines: [noticeText(notice)] });
102
+ }, [view, sessionId]);
82
103
  // Hiding depends only on the toast shown, so later state changes cannot cancel it.
83
104
  useEffect(() => {
84
105
  if (!toast)
@@ -111,19 +132,34 @@ export function RoutingChip({ sessionId, rpc, initialView, initiallyOpen = false
111
132
  useEffect(() => {
112
133
  let active = true;
113
134
  primed.current = false;
135
+ noticePrimed.current = false;
114
136
  seen.current = undefined;
115
137
  viewSession.current = undefined;
116
138
  setToast(undefined);
117
- const refresh = () => { void callRouting(rpc, sessionId).then(value => { if (active) {
118
- viewSession.current = sessionId;
119
- setView(value);
120
- setError('');
121
- ensureSubscription();
122
- } }, reason => { if (active)
123
- setError(String(reason)); }); };
139
+ let poll;
140
+ const refresh = () => {
141
+ void callRouting(rpc, sessionId).then(value => {
142
+ if (!active)
143
+ return;
144
+ // Child sessions have no AUTO chip; their origin never changes, so stop polling for good.
145
+ if (value?.applicable === false) {
146
+ setNotApplicable(true);
147
+ clearInterval(poll);
148
+ subscription.current?.stop();
149
+ subscription.current = undefined;
150
+ return;
151
+ }
152
+ viewSession.current = sessionId;
153
+ setView(value);
154
+ setError('');
155
+ ensureSubscription();
156
+ }, reason => { if (active)
157
+ setError(String(reason)); });
158
+ };
124
159
  refreshRef.current = refresh;
160
+ setNotApplicable(false);
125
161
  refresh();
126
- const poll = setInterval(refresh, 3000);
162
+ poll = setInterval(refresh, 3000);
127
163
  return () => {
128
164
  active = false;
129
165
  clearInterval(poll);
@@ -180,8 +216,11 @@ export function RoutingChip({ sessionId, rpc, initialView, initiallyOpen = false
180
216
  h('p', { key: 'depth', className: 'amr-muted' }, `AUTO · ${view?.depth ?? '尚无判断'};主模型不变,推理投入按请求应用;子任务自动选配。`),
181
217
  off && h('p', { key: 'disabled', role: 'status', className: 'amr-error' }, `Jev 停用(${off.category}):${hint}。${new Date(off.at).toLocaleString()}。点击 AUTO 重试。`),
182
218
  h('p', { key: 'data', className: 'amr-muted' }, 'AUTO 会把脱敏任务文本发送给设置中所选的决策服务判断。'),
219
+ view?.childNotice && h('p', { key: 'notice', role: 'status', className: 'amr-muted' }, noticeText(view.childNotice)),
183
220
  error && h('p', { key: 'error', role: 'alert', className: 'amr-error' }, error),
184
221
  ];
222
+ if (notApplicable)
223
+ return null;
185
224
  return h('span', { ref: (el) => { rootRef.current = el; remember(); }, className: 'amr-root', onMouseEnter: on('enter'), onMouseLeave: on('leave'), onFocus: on('focus'), onBlur: on('blur') }, h('style', null, CSS), h('button', {
186
225
  type: 'button', className: 'amr-trigger', disabled: busy || !view, onClick: () => { if (locked)
187
226
  return; void toggle(); },
package/lib/client.js CHANGED
@@ -312,11 +312,6 @@ window.__ModuleLoader__.load({
312
312
  id: "claude",
313
313
  label: "Claude",
314
314
  modelPrefixes: ["claude-"],
315
- transports: {
316
- claude: "",
317
- "dsh-magpie-connect": "claude/",
318
- "dsh-magpie-connector": "claude/"
319
- },
320
315
  effortStarts: {
321
316
  "claude-opus-5-5": "medium",
322
317
  "claude-sonnet-5-5": "medium",
@@ -335,13 +330,8 @@ window.__ModuleLoader__.load({
335
330
  id: "codex",
336
331
  label: "GPT",
337
332
  modelPrefixes: ["gpt-"],
338
- transports: {
339
- codex: "",
340
- "dsh-magpie-connect": "codex/",
341
- "dsh-magpie-connector": "codex/"
342
- },
343
333
  effortStarts: {
344
- "gpt-6.1-sol": "medium",
334
+ "gpt-6.1-sol": "high",
345
335
  "gpt-6-luna": "high",
346
336
  "gpt-6-astra": "low"
347
337
  },
@@ -364,6 +354,14 @@ window.__ModuleLoader__.load({
364
354
  "xhigh"
365
355
  ];
366
356
  const bare = (model) => model.slice(model.lastIndexOf("/") + 1);
357
+ const prefixOf = (model) => model.slice(0, Math.max(0, model.lastIndexOf("/")));
358
+ const KEYS = /* @__PURE__ */ new Set([
359
+ "id",
360
+ "label",
361
+ "modelPrefixes",
362
+ "effortStarts",
363
+ "childModels"
364
+ ]);
367
365
  const overlaps = (a, b) => a.startsWith(b) || b.startsWith(a);
368
366
  /** Validates the whole list up front; any conflict throws and no partial registry exists. */
369
367
  function createRegistry(input) {
@@ -371,9 +369,9 @@ window.__ModuleLoader__.load({
371
369
  const ids = /* @__PURE__ */ new Set();
372
370
  const starts = /* @__PURE__ */ new Map();
373
371
  const prefixes = [];
374
- const childPrefixes = /* @__PURE__ */ new Map();
375
372
  for (const s of strategies) {
376
373
  if (!s.id) throw new Error("策略缺少 id");
374
+ for (const key of Object.keys(s)) if (!KEYS.has(key)) throw new Error(`策略 ${s.id} 含未知键:${key}`);
377
375
  if (ids.has(s.id)) throw new Error(`策略 id 重复:${s.id}`);
378
376
  ids.add(s.id);
379
377
  for (const prefix of s.modelPrefixes) {
@@ -391,18 +389,10 @@ window.__ModuleLoader__.load({
391
389
  if (starts.has(model)) throw new Error(`起点键重复:${model}(${starts.get(model)} 与 ${s.id})`);
392
390
  starts.set(model, s.id);
393
391
  }
394
- for (const capability of CAPABILITIES) if (typeof s.childModels?.[capability] !== "string" || !s.childModels[capability]) throw new Error(`策略 ${s.id} 缺少能力槽:${capability}`);
395
- for (const [provider, prefix] of Object.entries(s.transports)) {
396
- const existing = childPrefixes.get(provider) ?? [];
397
- const clash = existing.find((p) => p.prefix === "" || prefix === "" ? true : overlaps(p.prefix, prefix));
398
- if (clash) {
399
- if (clash.prefix === "" || prefix === "") throw new Error(`provider ${provider} 的空前缀只能属于一个策略(${clash.id} 与 ${s.id} 冲突)`);
400
- throw new Error(`provider ${provider} 下子模型前缀重叠:${prefix}(${s.id})与 ${clash.prefix}(${clash.id})`);
401
- }
402
- childPrefixes.set(provider, [...existing, {
403
- prefix,
404
- id: s.id
405
- }]);
392
+ for (const capability of CAPABILITIES) {
393
+ const slot = s.childModels?.[capability];
394
+ if (typeof slot !== "string" || !slot) throw new Error(`策略 ${s.id} 缺少能力槽:${capability}`);
395
+ if (slot.includes("/")) throw new Error(`策略 ${s.id} 的能力槽不能含 /:${slot}`);
406
396
  }
407
397
  }
408
398
  const byId = new Map(strategies.map((s) => [s.id, s]));
@@ -413,23 +403,17 @@ window.__ModuleLoader__.load({
413
403
  const name = bare(model).toLowerCase();
414
404
  return strategies.find((s) => s.modelPrefixes.some((p) => name.startsWith(p.toLowerCase())));
415
405
  },
416
- familyOfChild: (provider, model) => {
417
- const entries = childPrefixes.get(provider) ?? [];
418
- const hit = entries.find((e) => e.prefix !== "" && model.startsWith(e.prefix)) ?? (entries.length === 1 && entries[0].prefix === "" ? entries[0] : void 0);
419
- return hit ? byId.get(hit.id) : void 0;
420
- },
421
- childModel: (familyId, provider, capability) => {
422
- const s = byId.get(familyId);
423
- const prefix = s?.transports[provider];
424
- return s && prefix !== void 0 ? `${prefix}${s.childModels[capability]}` : void 0;
406
+ resolveSlot: (familyId, capability, parentModel, models) => {
407
+ const slot = byId.get(familyId)?.childModels[capability]?.toLowerCase();
408
+ if (!slot) return void 0;
409
+ const hits = models.filter((m) => bare(m).toLowerCase() === slot && prefixOf(m) === prefixOf(parentModel));
410
+ return hits.length === 1 ? hits[0] : void 0;
425
411
  },
426
412
  effortStart: (model) => {
427
413
  const name = bare(model);
428
414
  for (const s of strategies) if (Object.hasOwn(s.effortStarts, name)) return s.effortStarts[name];
429
415
  },
430
- hasProvider: (provider) => childPrefixes.has(provider),
431
416
  get: (familyId) => byId.get(familyId),
432
- providers: () => [...childPrefixes.keys()],
433
417
  ids: () => strategies.map((s) => s.id),
434
418
  labels: () => strategies.map((s) => s.label)
435
419
  });
@@ -842,11 +826,13 @@ window.__ModuleLoader__.load({
842
826
  "aria-hidden": true
843
827
  }, (0, react.createElement)("path", { d: "M3 5.5h9.5M10 3l2.5 2.5L10 8" }), (0, react.createElement)("path", { d: "M13 10.5H3.5M6 8l-2.5 2.5L6 13" }));
844
828
  }
829
+ const noticeText = (notice) => `最近子任务未路由:${notice.reason}(按 DSH 默认运行)`;
845
830
  function RoutingChip({ sessionId, rpc, initialView, initiallyOpen = false, initialToast, subscribeSelection }) {
846
831
  const [view, setView] = (0, react.useState)(initialView);
847
832
  const [open, setOpen] = (0, react.useState)(initiallyOpen);
848
833
  const [busy, setBusy] = (0, react.useState)(false);
849
834
  const [error, setError] = (0, react.useState)("");
835
+ const [notApplicable, setNotApplicable] = (0, react.useState)(false);
850
836
  const timer = (0, react.useRef)(void 0);
851
837
  const [toast, setToast] = (0, react.useState)(initialToast);
852
838
  const rootRef = (0, react.useRef)(null);
@@ -876,6 +862,25 @@ window.__ModuleLoader__.load({
876
862
  remember();
877
863
  setToast(next);
878
864
  }, [view, sessionId]);
865
+ const seenNotice = (0, react.useRef)(void 0);
866
+ const noticePrimed = (0, react.useRef)(false);
867
+ (0, react.useEffect)(() => {
868
+ if (!view || viewSession.current !== sessionId) return;
869
+ const notice = view.childNotice;
870
+ if (!noticePrimed.current) {
871
+ noticePrimed.current = true;
872
+ seenNotice.current = notice?.id;
873
+ return;
874
+ }
875
+ if (!notice || notice.id === seenNotice.current) return;
876
+ seenNotice.current = notice.id;
877
+ if (!view.decisionToast) return;
878
+ remember();
879
+ setToast({
880
+ id: -notice.id,
881
+ lines: [noticeText(notice)]
882
+ });
883
+ }, [view, sessionId]);
879
884
  (0, react.useEffect)(() => {
880
885
  if (!toast) return;
881
886
  const hide = setTimeout(() => setToast(void 0), TOAST_MS);
@@ -910,24 +915,33 @@ window.__ModuleLoader__.load({
910
915
  (0, react.useEffect)(() => {
911
916
  let active = true;
912
917
  primed.current = false;
918
+ noticePrimed.current = false;
913
919
  seen.current = void 0;
914
920
  viewSession.current = void 0;
915
921
  setToast(void 0);
922
+ let poll;
916
923
  const refresh = () => {
917
924
  callRouting(rpc, sessionId).then((value) => {
918
- if (active) {
919
- viewSession.current = sessionId;
920
- setView(value);
921
- setError("");
922
- ensureSubscription();
925
+ if (!active) return;
926
+ if (value?.applicable === false) {
927
+ setNotApplicable(true);
928
+ clearInterval(poll);
929
+ subscription.current?.stop();
930
+ subscription.current = void 0;
931
+ return;
923
932
  }
933
+ viewSession.current = sessionId;
934
+ setView(value);
935
+ setError("");
936
+ ensureSubscription();
924
937
  }, (reason) => {
925
938
  if (active) setError(String(reason));
926
939
  });
927
940
  };
928
941
  refreshRef.current = refresh;
942
+ setNotApplicable(false);
929
943
  refresh();
930
- const poll = setInterval(refresh, 3e3);
944
+ poll = setInterval(refresh, 3e3);
931
945
  return () => {
932
946
  active = false;
933
947
  clearInterval(poll);
@@ -1006,12 +1020,18 @@ window.__ModuleLoader__.load({
1006
1020
  key: "data",
1007
1021
  className: "amr-muted"
1008
1022
  }, "AUTO 会把脱敏任务文本发送给设置中所选的决策服务判断。"),
1023
+ view?.childNotice && (0, react.createElement)("p", {
1024
+ key: "notice",
1025
+ role: "status",
1026
+ className: "amr-muted"
1027
+ }, noticeText(view.childNotice)),
1009
1028
  error && (0, react.createElement)("p", {
1010
1029
  key: "error",
1011
1030
  role: "alert",
1012
1031
  className: "amr-error"
1013
1032
  }, error)
1014
1033
  ];
1034
+ if (notApplicable) return null;
1015
1035
  return (0, react.createElement)("span", {
1016
1036
  ref: (el) => {
1017
1037
  rootRef.current = el;
package/lib/index.js CHANGED
@@ -11,6 +11,7 @@ import { policyHost } from './policy-host.js';
11
11
  import { PolicyStore } from './policy-store.js';
12
12
  import { currentMainSelection, mainSupported } from './support.js';
13
13
  import { createRegistry, registry as builtin } from './strategies/index.js';
14
+ const SCOPE_NOTE = '按官方工具 ALS 上下文、同父与 startup 绑定;不能区分同窗口插件旁支,旁支可能一并受管。';
14
15
  export const name = 'dsh-auto-model-routing';
15
16
  /** Child decision records. The historical directory name is kept on purpose: records are not migrated. */
16
17
  export const CHILD_DECISIONS_DIR = 'state/auto-model-routing/policy-v2';
@@ -19,7 +20,7 @@ export function apply(ctx, config = {}) {
19
20
  const home = config.home ?? process.env.DSH_HOME ?? resolve(homedir(), '.dsh');
20
21
  const registry = config.strategies ? createRegistry(config.strategies) : builtin;
21
22
  const settings = new SettingsStore(resolve(home, 'plugins/dsh-auto-model-routing/config.json'));
22
- const hostSettings = settingsHost(ctx, settings, registry);
23
+ const hostSettings = settingsHost(ctx, settings);
23
24
  const jev = new JevClient({ get: hostSettings.credential }, config.jevBase, config.jevTimeoutMs ?? (() => settings.read().settings.jevTimeoutMs), config.jevBase ? undefined : () => settings.read().settings.decisionService);
24
25
  const stateFiles = new FileStateStore(resolve(home, 'state/auto-model-routing/sessions'));
25
26
  const state = new RoutingState(stateFiles);
@@ -27,7 +28,7 @@ export function apply(ctx, config = {}) {
27
28
  // Official writer is available only while the session controller is mounted.
28
29
  let selectModel;
29
30
  const policy = policyHost(ctx, { feedback, jev, state, selectModel: async (request) => { if (!selectModel)
30
- throw new Error('session controller is unavailable'); await selectModel(request); }, store: new PolicyStore(resolve(home, CHILD_DECISIONS_DIR), registry), settings: () => settings.read().settings, catalog: () => hostSettings.catalog(), registry });
31
+ throw new Error('session controller is unavailable'); await selectModel(request); }, store: new PolicyStore(resolve(home, CHILD_DECISIONS_DIR), registry), settings: () => settings.read().settings, catalog: provider => hostSettings.catalog(provider), registry });
31
32
  const toastOn = () => settings.read().settings.decisionToast;
32
33
  ctx.effect(() => ctx.tools.register(routeSubagentTool(jev, { agents: ctx.agents, state, readSettings: () => settings.read().settings, catalog: hostSettings.catalog, registry })));
33
34
  ctx.on('agent/created', ({ agent, source }) => {
@@ -57,6 +58,57 @@ export function apply(ctx, config = {}) {
57
58
  child.effect(() => () => { selectModel = undefined; });
58
59
  });
59
60
  ctx.inject(['connection', 'sessionController'], child => {
61
+ const modeRequest = async (operation, payload) => {
62
+ const resolveAgent = async (id) => {
63
+ const result = await child.sessionController.resolveAgent(id);
64
+ return 'agent' in result ? ref(result.agent) : undefined;
65
+ };
66
+ // Child sessions have no AUTO chip: answer before any support check, lock or decoration.
67
+ if (operation === 'state' && typeof payload?.sessionId === 'string') {
68
+ const target = await resolveAgent(payload.sessionId);
69
+ if (target?.origin === 'subagent')
70
+ return Response.json({ applicable: false });
71
+ }
72
+ let manualDecision;
73
+ if (operation === 'setMode' && typeof payload?.sessionId === 'string') {
74
+ const id = payload.sessionId, mode = payload.mode;
75
+ if ((mode === 'AUTO' || mode === 'MANUAL') && state.read(id).mode !== mode && await resolveAgent(id)) {
76
+ const decisionId = policy.appliedDecision(id);
77
+ if (mode === 'MANUAL' && decisionId !== undefined)
78
+ manualDecision = { sessionId: id, decisionId };
79
+ policy.invalidate(id);
80
+ }
81
+ }
82
+ const response = await endpoint(operation, payload, resolveAgent, state);
83
+ if (!response.ok)
84
+ return response;
85
+ if (manualDecision) {
86
+ try {
87
+ feedback.fact(manualDecision.sessionId, manualDecision.decisionId, 'manual-switch');
88
+ }
89
+ catch {
90
+ ctx.logger.warn('auto-model-routing: could not record MANUAL switch');
91
+ }
92
+ }
93
+ const sessionId = payload.sessionId;
94
+ const resolved = await child.sessionController.resolveAgent(sessionId).catch(() => undefined);
95
+ const selection = resolved && 'agent' in resolved ? currentMainSelection(ctx, resolved.agent) : undefined;
96
+ const supported = mainSupported(selection, registry);
97
+ if (!supported && state.read(sessionId).mode === 'AUTO') {
98
+ state.setMode(sessionId, 'MANUAL');
99
+ policy.invalidate(sessionId);
100
+ }
101
+ const { previous: _previous, ...persisted } = await response.json();
102
+ return Response.json({ ...persisted, mode: state.read(sessionId).mode, ...policy.mainStatus(sessionId), ...(policy.childNotice(sessionId) ? { childNotice: policy.childNotice(sessionId) } : {}), ...(toastOn() ? { decisionToast: true } : {}), autoSupport: { supported, ...(selection ? { provider: selection.provider, model: selection.model } : {}) } });
103
+ };
104
+ child.provide('autoModelRouting', { call: async (operation, payload) => {
105
+ if (operation !== 'state' && operation !== 'setMode')
106
+ throw new Error('Unsupported routing operation');
107
+ const response = await modeRequest(operation, payload);
108
+ if (!response.ok)
109
+ throw new Error('Routing mode request rejected');
110
+ return response.json();
111
+ } });
60
112
  for (const operation of ['state', 'setMode', 'label', 'observe', 'settings', 'saveSettings', 'setKey', 'testDecision', 'childRouting']) {
61
113
  child.effect(() => child.connection.fetch.register({
62
114
  path: `/api/auto-model-routing.${operation}`,
@@ -77,54 +129,15 @@ export function apply(ctx, config = {}) {
77
129
  const result = await child.sessionController.resolveAgent(id);
78
130
  return 'agent' in result ? ref(result.agent) : undefined;
79
131
  };
80
- const resolveSessionAgent = async (id) => {
81
- const result = await child.sessionController.resolveAgent(id);
82
- return 'agent' in result ? result.agent : undefined;
83
- };
84
132
  const payload = enveloped ? wire.payload : body;
85
- let manualDecision;
86
- if (operation === 'setMode' && typeof payload?.sessionId === 'string') {
87
- const id = payload.sessionId;
88
- const mode = payload.mode;
89
- if ((mode === 'AUTO' || mode === 'MANUAL') && state.read(id).mode !== mode && await resolveAgent(id)) {
90
- const decisionId = policy.appliedDecision(id);
91
- if (mode === 'MANUAL' && decisionId !== undefined)
92
- manualDecision = { sessionId: id, decisionId };
93
- policy.invalidate(id);
94
- }
95
- }
96
133
  const response = operation === 'childRouting'
97
134
  ? (typeof payload?.sessionId === 'string'
98
- ? Response.json(policy.status(payload.sessionId) ?? { status: 'unsupported', reason: '没有已绑定的子任务决策' })
135
+ ? Response.json((record => record ? { ...record, scopeNote: SCOPE_NOTE } : { status: 'unsupported', reason: '没有已绑定的子任务决策' })(policy.status(payload.sessionId)))
99
136
  : Response.json({ error: 'invalid request' }, { status: 400 }))
100
137
  : operation === 'settings' || operation === 'saveSettings' || operation === 'setKey' || operation === 'testDecision'
101
138
  ? await hostSettings.endpoint(operation, payload)
102
139
  : operation === 'observe' ? await observeEndpoint(payload, resolveAgent, feedback, () => ctx.logger.warn('auto-model-routing: could not record exposure'))
103
- : operation === 'label' ? await labelEndpoint(payload, resolveAgent, feedback) : await endpoint(operation, payload, resolveAgent, state);
104
- if (manualDecision && response.ok) {
105
- try {
106
- feedback.fact(manualDecision.sessionId, manualDecision.decisionId, 'manual-switch');
107
- }
108
- catch {
109
- ctx.logger.warn('auto-model-routing: could not record MANUAL switch');
110
- }
111
- }
112
- if ((operation === 'state' || operation === 'setMode') && response.ok) {
113
- const sessionId = payload.sessionId;
114
- const agent = await resolveSessionAgent(sessionId).catch(() => undefined);
115
- const selection = agent ? currentMainSelection(ctx, agent) : undefined;
116
- const supported = mainSupported(selection, registry);
117
- // Leaving AUTO is persisted: a pick outside the registered families must not keep judging turns.
118
- if (!supported && state.read(sessionId).mode === 'AUTO') {
119
- state.setMode(sessionId, 'MANUAL');
120
- policy.invalidate(sessionId);
121
- }
122
- const value = await response.json();
123
- const main = policy.mainStatus(sessionId);
124
- const { previous: _previous, ...persisted } = value;
125
- const result = { ...persisted, mode: state.read(sessionId).mode, ...main, ...(toastOn() ? { decisionToast: true } : {}), autoSupport: { supported, ...(selection ? { provider: selection.provider, model: selection.model } : {}) } };
126
- return Response.json(enveloped ? { type: 'server-response', rpcId: wire.rpcId, result: { ok: true, value: result } } : result);
127
- }
140
+ : operation === 'label' ? await labelEndpoint(payload, resolveAgent, feedback) : await modeRequest(operation, payload);
128
141
  if (!enveloped)
129
142
  return response;
130
143
  const value = await response.json();
@@ -17,16 +17,22 @@ export declare function policyHost(ctx: Context, deps: {
17
17
  state: RoutingState;
18
18
  store: PolicyStore;
19
19
  settings: () => Settings;
20
- catalog: () => Promise<RoutingCatalog>;
20
+ catalog: (provider?: string) => Promise<RoutingCatalog>;
21
21
  registry?: Registry;
22
22
  }): {
23
23
  appliedDecision: (id: string) => number | undefined;
24
24
  invalidate: (id: string) => void;
25
+ childNotice: (rootId: string) => {
26
+ id: number;
27
+ childId: string;
28
+ reason: string;
29
+ at: number;
30
+ } | undefined;
25
31
  status: (id: string) => {
26
32
  childId: string;
27
33
  parentId: string;
28
34
  rootId: string;
29
- status: "failed" | "recommended" | "unsupported" | "needs-context" | "applied";
35
+ status: "failed" | "recommended" | "needs-context" | "unsupported" | "applied";
30
36
  path: "ordinary" | "fork" | "teammate";
31
37
  explicit: {
32
38
  provider: string;
@@ -34,6 +40,11 @@ export declare function policyHost(ctx: Context, deps: {
34
40
  reasoningEffort?: string | undefined;
35
41
  };
36
42
  family?: string | undefined;
43
+ parentSelection?: {
44
+ provider: string;
45
+ model?: string | undefined;
46
+ reasoningEffort?: string | undefined;
47
+ } | undefined;
37
48
  decision?: {
38
49
  id: number;
39
50
  source: "main" | "subagent";