dsh-switchman 1.3.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -4,19 +4,26 @@
4
4
  * bundle per package; sibling files cannot be synchronously required).
5
5
  *
6
6
  * Surfaces, all reading the shared "dsh-switchman" locale namespace (zh/en):
7
- * - Phase 0: the team-autonomy badge in `conversation.session.header.actions`.
8
- * - Phase 0: the team-autonomy badge in `conversation.session.header.actions`.
7
+ * - Phase 0: the team-autonomy badge in `conversation.session.header.actions`,
8
+ * extended with the current session's actual model (provider/model@effort)
9
+ * for subagent sessions (live projection face, shared-snapshot fallback).
9
10
  * - Phase 1: a `settings.section` page with a "Language" card:
10
11
  * conversation / code-comment / document language preferences.
11
12
  * - Phase 2: a "Dispatch pools & ranking" card on the same page: six lane
12
13
  * pools and a capability ranking edited against the live model catalog
13
14
  * (served by the Host route family), plus the enforcement mode. Every
14
- * field shares one staged draft and one atomic fenced save.
15
+ * field shares one staged draft and one atomic fenced save. Selected
16
+ * routes outside the session's DSH-authorized child models (Host
17
+ * /authorized route) carry a ⚠ badge plus a one-line hint.
15
18
  * - Phase 3: a "Context watermark" card on the same page: tiered token
16
19
  * thresholds (soft/hard/force), the per-read budget, hard-tier behavior,
17
20
  * automatic handover, and the subagent context cap — number inputs with
18
21
  * per-field minimum plus soft<hard<force order validation, folded into
19
22
  * the same staged draft and one atomic save.
23
+ * - Phase 4: an "Agent teams" card on the same page: the teams-mode master
24
+ * switch, the whitelist-sync sub-switch (teams mode only), and the
25
+ * whitelist sync status line. The ⚠ unauthorized badges and hint render
26
+ * only in teams mode (plain subagent dispatch names no routes).
20
27
  */
21
28
 
22
29
  window.__ModuleLoader__.load({
@@ -148,6 +155,8 @@ window.__ModuleLoader__.load({
148
155
  'wmDenyMode',
149
156
  'wmAutoHandover',
150
157
  'wmSubagentCap',
158
+ 'teamsMode',
159
+ 'syncWhitelist',
151
160
  ];
152
161
 
153
162
  /** Stable identity of one provider/model route. */
@@ -230,6 +239,10 @@ window.__ModuleLoader__.load({
230
239
  const bool = (field, def) =>
231
240
  typeof value?.[field] === 'boolean' ? value[field] : def;
232
241
  return {
242
+ // 作用域枚举(host 快照缺失/非法值一律按 global 呈现);
243
+ // 放在首位,与 save() 组装的 desired 键序一致,保持
244
+ // sameValues 的无改动早退路径可用。
245
+ langScope: value?.langScope === 'project' ? 'project' : 'global',
233
246
  langConversation: string('langConversation'),
234
247
  langComments: string('langComments'),
235
248
  langDocs: string('langDocs'),
@@ -260,6 +273,8 @@ window.__ModuleLoader__.load({
260
273
  wmDenyMode: value?.wmDenyMode === 'deny' ? 'deny' : 'cap',
261
274
  wmAutoHandover: bool('wmAutoHandover', true),
262
275
  wmSubagentCap: bool('wmSubagentCap', true),
276
+ teamsMode: bool('teamsMode', false),
277
+ syncWhitelist: bool('syncWhitelist', false),
263
278
  };
264
279
  }
265
280
 
@@ -270,6 +285,7 @@ window.__ModuleLoader__.load({
270
285
  * numbers — never their input-string forms). */
271
286
  function canonical(values) {
272
287
  return JSON.stringify({
288
+ langScope: values.langScope,
273
289
  langConversation: values.langConversation,
274
290
  langComments: values.langComments,
275
291
  langDocs: values.langDocs,
@@ -306,6 +322,8 @@ window.__ModuleLoader__.load({
306
322
  wmDenyMode: values.wmDenyMode,
307
323
  wmAutoHandover: values.wmAutoHandover,
308
324
  wmSubagentCap: values.wmSubagentCap,
325
+ teamsMode: values.teamsMode,
326
+ syncWhitelist: values.syncWhitelist,
309
327
  });
310
328
  }
311
329
 
@@ -343,9 +361,26 @@ window.__ModuleLoader__.load({
343
361
  return index;
344
362
  }
345
363
 
364
+ /** Parse the /authorized answer (the session's DSH-authorized child
365
+ * models) into { enabled, keys } — null when unknown: a failed
366
+ * fetch, an unreadable service (both fields null), or a malformed
367
+ * shape all degrade to "no badges, no hint". A readable-but-empty
368
+ * whitelist yields keys = empty Set (every selected route drifts). */
369
+ function parseAuthorized(value) {
370
+ if (value === null || typeof value !== 'object') return null;
371
+ if (value.enabled !== true && value.enabled !== false) return null;
372
+ if (!Array.isArray(value.routes)) return null;
373
+ const keys = new Set();
374
+ for (const route of value.routes) {
375
+ const clean = sanitizeRoute(route);
376
+ if (clean !== null) keys.add(routeKey(clean));
377
+ }
378
+ return { enabled: value.enabled, keys };
379
+ }
380
+
346
381
  /** Checkbox rows for one pool: every live catalog model, checked or
347
- * not, plus this pool's saved routes that left the catalog (still
348
- * removable — mirrors the shipped model-selection page). */
382
+ * not, plus this pool's saved routes that left the catalog (still
383
+ * removable — mirrors the shipped model-selection page). */
349
384
  function poolRows(pool, catalogIndex) {
350
385
  const selected = new Set(pool.map(routeKey));
351
386
  const rows = [];
@@ -412,11 +447,30 @@ window.__ModuleLoader__.load({
412
447
  badge: '自主团队',
413
448
  badgeTip:
414
449
  'dsh-switchman:四个内置预设(standard / ptc / minimal / cordis)已注入自主智能体团队调度规程',
450
+ handoverActive: '交接进行中',
451
+ handoverPhaseBackup: '备份会话',
452
+ handoverPhaseCompacting: '压缩上下文',
453
+ handoverPhaseContinuation: '唤醒续接',
454
+ handoverActiveTip:
455
+ 'Switchman 正在后台交接:fork 备份会话并压缩上下文,完成后自动继续任务;期间会话静止属正常现象。',
456
+ visionHintNeedsPool:
457
+ '当前模型不支持读图:请在 dsh-switchman 设置中配置「视觉池」模型,之后用 /vision 发送图片。',
458
+ visionHintUseVision:
459
+ '当前模型不支持读图:图片请用 /vision 发送(将自动派发视觉池读取)。',
460
+ visionAutoConverted:
461
+ '图片直发被拒:已自动改用 /vision 重发(由视觉池读取)。',
415
462
  settingsTitle: 'dsh-switchman',
463
+ panelLabel: 'Switchman 调度中心',
416
464
  settingsDescription: '配置 dsh-switchman 的各项偏好。',
417
465
  langSectionTitle: '语言偏好',
418
466
  langSectionDescription:
419
467
  '控制智能体的对话、代码注释与文档撰写语言。',
468
+ langScopeLabel: '语言作用域',
469
+ langScopeHelp: '选择这三项语言偏好的存放位置。',
470
+ langScopeGlobal: '全局(本 profile)',
471
+ langScopeProject: '按项目(.switchman/lang.json)',
472
+ langProjectNote:
473
+ '按项目模式:各项目读取自身的 .switchman/lang.json;首次询问的答案会写入该文件,文件不存在时每个会话都会询问。以下三项仅在全局模式生效。',
420
474
  langConversationLabel: '对话语言',
421
475
  langConversationHelp:
422
476
  '智能体回复与讨论使用的语言;未设置时会在首次使用时询问一次并记住。',
@@ -478,6 +532,9 @@ window.__ModuleLoader__.load({
478
532
  catalogEmpty: '当前没有模型提供方公布模型。',
479
533
  catalogUnavailableGroup: '已保存但当前不可用',
480
534
  catalogUnavailable: '当前不可用',
535
+ unauthorizedBadge: '未在 DSH 设置的子智能体模型选择授权列表中',
536
+ unauthorizedHint:
537
+ '标有 ⚠ 的模型未在 DSH 设置 → 子智能体 → 模型选择 中授权,Agent 显式指定它们会被拒绝;去 DSH 设置授权,或让 Agent 走隐式派发。',
481
538
  rankTitle: '能力排序',
482
539
  rankDescription:
483
540
  '序号即能力序(最强在前),可为每项锚定 S/A/B/C 档。列表为六池选中 ∪ 已有排序;任意编辑都会固化当前显示顺序。',
@@ -524,16 +581,48 @@ window.__ModuleLoader__.load({
524
581
  wmSubagentFollowForce: '跟随 force 档(0)',
525
582
  wmCommands:
526
583
  '命令:/ctx-pause 暂停水位干预 · /ctx-resume 恢复干预 · /ctx-handover 立即备份并交接压缩。',
584
+ teamsSectionTitle: '智能体团队',
585
+ teamsSectionDescription:
586
+ '切换派发形态:关闭为纯 subagent 派发(DSH 出厂保守团队策略接管);开启后注入自主团队规程并启用 Agent Teams。',
587
+ teamsModeLabel: '智能体团队模式',
588
+ teamsModeHelp:
589
+ '开启后注入团队规程(默认委派 + 分级验证 + 任务板纪律),并自动启用 DSH 的 Agent Teams bundle;派发池的 ⚠ 授权徽标仅在开启时显示。',
590
+ syncWhitelistLabel: '同步子智能体模型白名单',
591
+ syncWhitelistHelp:
592
+ '独立开关(不依赖智能体团队模式):把六池选中模型的并集整体写入 DSH「设置 → 子智能体 → 模型选择」的授权列表;dsh-switchman 成为唯一事实源。',
593
+ whitelistSyncOk: '已同步 {count} 条({time})。',
594
+ whitelistSyncError: '同步失败:{error}',
595
+ whitelistSyncPending: '等待同步…',
596
+ whitelistSyncNote: '白名单变更仅对之后新建的会话生效。',
527
597
  },
528
598
  en: {
529
599
  badge: 'Team autonomy',
530
600
  badgeTip:
531
601
  'dsh-switchman: the four built-in presets (standard / ptc / minimal / cordis) carry the autonomous Agent Teams doctrine',
602
+ handoverActive: 'Handover in progress',
603
+ handoverPhaseBackup: 'forking backup',
604
+ handoverPhaseCompacting: 'compacting context',
605
+ handoverPhaseContinuation: 'waking continuation',
606
+ handoverActiveTip:
607
+ 'Switchman is handing over in the background: forking a backup session and compacting context; the task continues automatically when done. A still session during this window is expected.',
608
+ visionHintNeedsPool:
609
+ 'This model cannot read images: add vision-pool models in the dsh-switchman settings, then send images with /vision.',
610
+ visionHintUseVision:
611
+ 'This model cannot read images: send them with /vision (routed to the vision pool automatically).',
612
+ visionAutoConverted:
613
+ 'Image send was refused: automatically resubmitting via /vision (read by the vision pool).',
532
614
  settingsTitle: 'dsh-switchman',
615
+ panelLabel: 'Switchman',
533
616
  settingsDescription: 'Configure dsh-switchman preferences.',
534
617
  langSectionTitle: 'Language',
535
618
  langSectionDescription:
536
619
  'Controls the language of agent replies, code comments, and authored documents.',
620
+ langScopeLabel: 'Language scope',
621
+ langScopeHelp: 'Where these language preferences are stored.',
622
+ langScopeGlobal: 'Global (this profile)',
623
+ langScopeProject: 'Per project (.switchman/lang.json)',
624
+ langProjectNote:
625
+ 'Per-project mode: every project reads its own .switchman/lang.json; first-use answers are saved there, and sessions ask until it exists. The three fields below apply only in global mode.',
537
626
  langConversationLabel: 'Conversation language',
538
627
  langConversationHelp:
539
628
  'The language agents reply and discuss in; when unset, you are asked once on first use and the choice is remembered.',
@@ -596,6 +685,9 @@ window.__ModuleLoader__.load({
596
685
  catalogEmpty: 'No model provider currently advertises a model.',
597
686
  catalogUnavailableGroup: 'Saved but currently unavailable',
598
687
  catalogUnavailable: 'currently unavailable',
688
+ unauthorizedBadge: 'not authorized for explicit child dispatch in DSH Settings (subagents: model selection)',
689
+ unauthorizedHint:
690
+ 'Models marked ⚠ are not authorized in DSH Settings → Subagents → model selection; agents naming them explicitly will be denied — authorize them in DSH Settings, or let agents dispatch implicitly.',
599
691
  rankTitle: 'Capability ranking',
600
692
  rankDescription:
601
693
  'Numbered strongest first; each entry may anchor an S/A/B/C tier. The list is pool selections ∪ the saved ranking; any edit persists the displayed order.',
@@ -642,6 +734,19 @@ window.__ModuleLoader__.load({
642
734
  wmSubagentFollowForce: 'Follow the force tier (0)',
643
735
  wmCommands:
644
736
  'Commands: /ctx-pause suspends watermark actions · /ctx-resume resumes them · /ctx-handover backs up and hands over now.',
737
+ teamsSectionTitle: 'Agent teams',
738
+ teamsSectionDescription:
739
+ "Switch the dispatch shape: off is plain subagent dispatch (DSH's factory conservative team policy takes over); on injects the autonomous team doctrine and enables Agent Teams.",
740
+ teamsModeLabel: 'Agent teams mode',
741
+ teamsModeHelp:
742
+ 'When on, the team doctrine (delegate by default + tiered verification + task-board discipline) is injected and the DSH Agent Teams bundle is enabled; the dispatch pools ⚠ authorization badges show only in this mode.',
743
+ syncWhitelistLabel: 'Sync the subagent model whitelist',
744
+ syncWhitelistHelp:
745
+ 'Independent switch (does not require agent teams mode): writes the union of the six pools over the DSH "Settings → Subagents → model selection" authorization list; dsh-switchman becomes the single source of truth.',
746
+ whitelistSyncOk: 'Synced {count} entries ({time}).',
747
+ whitelistSyncError: 'Sync failed: {error}',
748
+ whitelistSyncPending: 'Waiting to sync…',
749
+ whitelistSyncNote: 'Whitelist changes only affect sessions created afterwards.',
645
750
  },
646
751
  };
647
752
 
@@ -649,15 +754,215 @@ window.__ModuleLoader__.load({
649
754
  // Session-header badge (Phase 0, unchanged)
650
755
  // ------------------------------------------------------------------
651
756
 
652
- function SwitchmanBadge({ locale, t }) {
757
+ /** Rotating-arc spinner (SVG SMIL animateTransform): the restricted
758
+ * dynamic-module runtime exposes no CSS insertion here, and SMIL
759
+ * animates without lifecycle code or a React state loop. */
760
+ function HandoverSpinner() {
761
+ return h(
762
+ 'svg',
763
+ {
764
+ viewBox: '0 0 16 16',
765
+ width: 12,
766
+ height: 12,
767
+ 'aria-hidden': true,
768
+ style: { flex: 'none' },
769
+ },
770
+ h(
771
+ 'circle',
772
+ {
773
+ cx: 8,
774
+ cy: 8,
775
+ r: 6,
776
+ fill: 'none',
777
+ stroke: 'currentColor',
778
+ strokeWidth: 2,
779
+ strokeDasharray: '9 19',
780
+ strokeLinecap: 'round',
781
+ },
782
+ h('animateTransform', {
783
+ attributeName: 'transform',
784
+ type: 'rotate',
785
+ from: '0 8 8',
786
+ to: '360 8 8',
787
+ dur: '1s',
788
+ repeatCount: 'indefinite',
789
+ }),
790
+ ),
791
+ );
792
+ }
793
+
794
+ function SwitchmanBadge({
795
+ locale,
796
+ t,
797
+ sessionId,
798
+ useProjection,
799
+ useSessions,
800
+ }) {
801
+
653
802
  // Subscription only: stable snapshot via getLocale() re-renders on switch.
654
803
  React.useSyncExternalStore(
655
804
  (fn) => locale.subscribe(fn),
656
805
  () => locale.getLocale(),
657
806
  );
807
+ // Live handover cue: during the fork+compact window the session
808
+ // GUI has no motion of its own, so the badge polls the Host's
809
+ // handover-state route (phases recorded from runHandover's first
810
+ // synchronous line) and flips to an animated status chip. Fast
811
+ // cadence while in flight, slow otherwise; a failed fetch or an
812
+ // older Host reads as "no handover" and never blocks the badge.
813
+ const [handover, setHandover] = React.useState(null);
814
+ React.useEffect(() => {
815
+ let stopped = false;
816
+ let timer = null;
817
+ const tick = async () => {
818
+ let live = null;
819
+ // NOTE: the handover-state route is not session-scoped, so
820
+ // this reflects ANY in-flight handover (cross-session bleed
821
+ // with several open sessions) — accepted: cosmetic, and
822
+ // the desktop GUI renders one session header at a time.
823
+ try {
824
+ const reply = await api.get('handover-state');
825
+ if (reply?.ok && reply.value?.count > 0)
826
+ live = reply.value.handovers[0] ?? null;
827
+ } catch {
828
+ live = null;
829
+ }
830
+ if (stopped) return;
831
+ setHandover(live);
832
+ timer = setTimeout(tick, live === null ? 10_000 : 3_000);
833
+ };
834
+ void tick();
835
+ return () => {
836
+ stopped = true;
837
+ if (timer !== null) clearTimeout(timer);
838
+ };
839
+ }, []);
840
+ // ---- Session model line ----
841
+ // The slot is session-scoped, so `sessionId` names the open session
842
+ // and `useProjection` reads its live projection faces (standard kit
843
+ // members; both degrade to no-ops outside the kit, same guard shape
844
+ // as the vision dock entry). Switching sessions re-materializes the
845
+ // standard props, so the face re-binds and the old subscription is
846
+ // dropped by the hook machinery — no manual lifecycle here.
847
+ const projectionHook =
848
+ typeof useProjection === 'function' ? useProjection : () => undefined;
849
+ const liveSelection = projectionHook('modelSelection');
850
+ // NEVER touch services this plugin did not declare in inject:
851
+ // the dynamic-module guard THROWS on `ctx.<undeclared>` access
852
+ // (ctx.sessions did exactly that and the slot occurrence error
853
+ // boundary swallowed it — the badge silently vanished this way).
854
+ // Session facts arrive only through framework-bound kit hooks, and
855
+ // every selector must return a value-stable primitive.
856
+ const sessionsHook =
857
+ typeof useSessions === 'function' ? useSessions : () => undefined;
858
+ // Durable metadata separates subagent sessions from root ones
859
+ // (summary `origin`/`parentId`). A root session's model already has
860
+ // the composer's model selector, so the line stays subagent-only.
861
+ const subagentKey = sessionsHook((list) => {
862
+ if (list == null) return 'absent';
863
+ const summary =
864
+ sessionId != null ? list.byId?.[sessionId] : undefined;
865
+ if (summary === undefined) return 'absent';
866
+ return summary.origin === 'subagent' ||
867
+ summary.parentId !== undefined
868
+ ? 'subagent'
869
+ : 'root';
870
+ });
871
+ const isSubagent = subagentKey === 'subagent';
872
+ // Fallback for sessions whose retained face has not carried the
873
+ // key yet: the manager's shared list snapshot often already holds
874
+ // explicitly loaded projections (sidebar-visible subagents).
875
+ const fallbackText = sessionsHook((list) => {
876
+ if (list == null) return '';
877
+ const selection =
878
+ sessionId != null
879
+ ? list.projectionsBySession?.[sessionId]?.values
880
+ ?.modelSelection
881
+ : undefined;
882
+ const model = selection?.next ?? selection?.lastUsed;
883
+ if (model == null) return '';
884
+ return `${model.provider}/${model.model}${
885
+ model.reasoningEffort ? `@${model.reasoningEffort}` : ''
886
+ }`;
887
+ });
888
+ const liveModel =
889
+ liveSelection?.next ?? liveSelection?.lastUsed ?? null;
890
+ const modelText =
891
+ liveModel == null
892
+ ? (fallbackText ?? '')
893
+ : `${liveModel.provider}/${liveModel.model}${
894
+ liveModel.reasoningEffort
895
+ ? `@${liveModel.reasoningEffort}`
896
+ : ''
897
+ }`;
898
+ const modelChip =
899
+ isSubagent && modelText !== ''
900
+ ? h(
901
+ 'span',
902
+ {
903
+ title: modelText,
904
+ 'data-dsh-switchman': 'session-model',
905
+ style: {
906
+ borderRadius: 'var(--dsw-radius-xs)',
907
+ height: '22px',
908
+ color: 'var(--dsw-alias-label-tertiary)',
909
+ whiteSpace: 'nowrap',
910
+ display: 'inline-flex',
911
+ alignItems: 'center',
912
+ gap: '4px',
913
+ padding: '0 8px',
914
+ fontSize: '12px',
915
+ lineHeight: '22px',
916
+ flex: 'none',
917
+ },
918
+ },
919
+ h('span', { 'aria-hidden': true, style: { flex: 'none' } }, '◇'),
920
+ h('span', null, modelText),
921
+ )
922
+ : null;
923
+ /** Append the model chip without disturbing the status chip shapes. */
924
+ const withModelChip = (chip) =>
925
+ modelChip === null ? chip : h(React.Fragment, null, chip, modelChip);
926
+ if (handover !== null) {
927
+ const phaseKey =
928
+ handover.phase === 'backup'
929
+ ? 'handoverPhaseBackup'
930
+ : handover.phase === 'continuation'
931
+ ? 'handoverPhaseContinuation'
932
+ : 'handoverPhaseCompacting';
933
+ const seconds = Math.max(
934
+ 0,
935
+ Math.round((Date.now() - (handover.at ?? Date.now())) / 1000),
936
+ );
937
+ const status = h(
938
+ 'span',
939
+ {
940
+ title: `${t('handoverActiveTip')}(${seconds}s)`,
941
+ role: 'status',
942
+ 'data-dsh-switchman': 'handover-active',
943
+ style: {
944
+ borderRadius: 'var(--dsw-radius-xs)',
945
+ background: 'var(--dsw-alias-interactive-bg-hover, transparent)',
946
+ color: 'var(--dsw-alias-state-business-primary, inherit)',
947
+ height: '22px',
948
+ whiteSpace: 'nowrap',
949
+ display: 'inline-flex',
950
+ alignItems: 'center',
951
+ gap: '4px',
952
+ padding: '0 8px',
953
+ fontSize: '12px',
954
+ lineHeight: '22px',
955
+ flex: 'none',
956
+ },
957
+ },
958
+ h(HandoverSpinner, null),
959
+ h('span', null, `${t('handoverActive')} · ${t(phaseKey)}`),
960
+ );
961
+ return withModelChip(status);
962
+ }
658
963
  const text = t('badge');
659
964
  const tip = t('badgeTip');
660
- return h(
965
+ const status = h(
661
966
  'span',
662
967
  {
663
968
  title: tip,
@@ -684,6 +989,243 @@ window.__ModuleLoader__.load({
684
989
  h('span', { 'aria-hidden': true, style: { flex: 'none' } }, '⚡'),
685
990
  h('span', null, text),
686
991
  );
992
+ return withModelChip(status);
993
+ }
994
+
995
+ /** Composer-dock vision entry. Two jobs:
996
+ *
997
+ * 1. Guidance chip — DSH refuses to send pasted images while the
998
+ * session model has no image input; switchman's unlock is the
999
+ * /vision command backed by the vision pool. The chip renders
1000
+ * whenever image support is not positively confirmed (probe
1001
+ * false, or null = unresolved — the common live case: the
1002
+ * Host-side model-info resolution often cannot resolve custom
1003
+ * routes), polling the Host route at a slow fixed cadence;
1004
+ * any failure renders nothing.
1005
+ * 2. Reactive auto-conversion (v1) — when a plain send is refused
1006
+ * host-side (session/attachment-invalid), rewrite the draft as
1007
+ * "/vision <original text>" and resubmit once; the claimed
1008
+ * command submit carries the restored draft attachments past
1009
+ * the model gate to the /vision handler. The refusal code is
1010
+ * the authoritative image-gate signal; the probe's
1011
+ * imageCapable only brakes the conversion when it positively
1012
+ * says true. Session-scoped dock entries receive the standard
1013
+ * kit (inputActions, useInput, useSession) automatically;
1014
+ * loop safety = one conversion per promptError object
1015
+ * identity + never converting a draft that already starts
1016
+ * with "/". */
1017
+ function VisionDockEntry({ t, sessionId, inputActions, useInput, useSession }) {
1018
+ const [vision, setVision] = React.useState(null);
1019
+ const [notice, setNotice] = React.useState(false);
1020
+ const handled = React.useRef(null);
1021
+ const latest = React.useRef({ vision: null, draft: '', attachmentIds: [] });
1022
+ React.useEffect(() => {
1023
+ let stopped = false;
1024
+ let timer = null;
1025
+ // The probe must judge the CURRENT conversation's model: pass
1026
+ // the session scope's identity so the Host resolves
1027
+ // imageCapable from that session's model selection (subagent
1028
+ // sessions included); without it the Host falls back to the
1029
+ // root session and mislabels text-only children. Restarting
1030
+ // the loop on `sessionId` re-probes immediately on a switch.
1031
+ const query =
1032
+ typeof sessionId === 'string' && sessionId !== ''
1033
+ ? `?session=${encodeURIComponent(sessionId)}`
1034
+ : '';
1035
+ const tick = async () => {
1036
+ let next = null;
1037
+ try {
1038
+ const reply = await api.get(`vision-state${query}`);
1039
+ if (reply?.ok && reply.value && typeof reply.value === 'object')
1040
+ next = {
1041
+ poolConfigured: reply.value.poolConfigured === true,
1042
+ imageCapable: reply.value.imageCapable,
1043
+ };
1044
+ } catch {
1045
+ next = null;
1046
+ }
1047
+ if (stopped) return;
1048
+ setVision(next);
1049
+ timer = setTimeout(tick, 15_000);
1050
+ };
1051
+ void tick();
1052
+ return () => {
1053
+ stopped = true;
1054
+ if (timer !== null) clearTimeout(timer);
1055
+ };
1056
+ }, [sessionId]);
1057
+ // Standard kit props are guaranteed by the session scope, but the
1058
+ // hooks must be called unconditionally — degrade to no-op sources
1059
+ // if a future shell ever mounts this entry outside that scope.
1060
+ const inputHook =
1061
+ typeof useInput === 'function' ? useInput : () => () => ({ draft: '', attachmentIds: [] });
1062
+ const sessionHook =
1063
+ typeof useSession === 'function' ? useSession : () => () => null;
1064
+ const input = inputHook((s) => s) ?? {};
1065
+ const promptError = sessionHook((s) => s.promptError);
1066
+ latest.current = {
1067
+ vision,
1068
+ draft: typeof input.draft === 'string' ? input.draft : '',
1069
+ attachmentIds: Array.isArray(input.attachmentIds) ? input.attachmentIds : [],
1070
+ };
1071
+ React.useEffect(() => {
1072
+ if (promptError == null || promptError === handled.current) return;
1073
+ handled.current = promptError;
1074
+ if (promptError.op !== 'send') return; // stop failures are not ours
1075
+ const code = promptError.error?.code;
1076
+ if (code !== 'session/attachment-invalid' && code !== 'subagent/attachment-invalid') return;
1077
+ // Let InputBar's failure restore of the draft and attachments
1078
+ // land before reading them.
1079
+ const timer = setTimeout(() => {
1080
+ const snap = latest.current;
1081
+ // The refusal code below is the authoritative image-gate
1082
+ // signal; vision.imageCapable is only a secondary brake.
1083
+ // The Host probe legitimately yields null (unknown) when
1084
+ // its model-info resolution fails, so skip ONLY when the
1085
+ // probe positively says the model reads images.
1086
+ if (snap.vision?.imageCapable === true) return;
1087
+ const text = snap.draft.trim();
1088
+ if (text.startsWith('/')) return; // already a command path — never loop
1089
+ if (snap.attachmentIds.length === 0) return;
1090
+ if (typeof inputActions?.setDraft !== 'function' || typeof inputActions?.submit !== 'function')
1091
+ return;
1092
+ inputActions.setDraft(text === '' ? '/vision' : `/vision ${text}`);
1093
+ inputActions.submit();
1094
+ setNotice(true);
1095
+ setTimeout(() => setNotice(false), 6_000);
1096
+ }, 300);
1097
+ return () => clearTimeout(timer);
1098
+ }, [promptError, inputActions]);
1099
+ if (vision === null && !notice) return null;
1100
+ // The dock zone spans the full conversation width while the
1101
+ // composer card is a centered column (max-width contract). The
1102
+ // hero state narrows the whole stack, the active state does
1103
+ // not — so a bare <p> lands hard against the far left once a
1104
+ // conversation starts. Mirror the shell's own QueueDock column
1105
+ // formula (same CSS custom properties) to keep every line's
1106
+ // left edge tracking the input card in both states; fallback
1107
+ // values degrade to the shell defaults if a variable is unset.
1108
+ const dockColumnStyle = {
1109
+ boxSizing: 'border-box',
1110
+ width: 'calc(100% - var(--dsh-composer-side-clearance, 16px) - var(--dsh-composer-side-clearance, 16px))',
1111
+ maxWidth: 'var(--dsh-composer-card-max-width, 780px)',
1112
+ margin: '0 auto',
1113
+ flex: 'none',
1114
+ // Keep the rhythm between the notice and hint lines equal to
1115
+ // the composer stack gap they no longer participate in.
1116
+ display: 'flex',
1117
+ flexDirection: 'column',
1118
+ gap: 'var(--dsh-composer-stack-gap, 6px)',
1119
+ };
1120
+ return h(
1121
+ 'div',
1122
+ {
1123
+ 'data-dsh-switchman': 'vision-dock-column',
1124
+ style: dockColumnStyle,
1125
+ },
1126
+ notice
1127
+ ? h(
1128
+ 'p',
1129
+ {
1130
+ 'data-dsh-switchman': 'vision-auto-converted',
1131
+ style: {
1132
+ margin: '0',
1133
+ padding: '2px 0',
1134
+ color: 'var(--dsw-alias-label-secondary)',
1135
+ fontSize: '12px',
1136
+ lineHeight: '1.6',
1137
+ },
1138
+ },
1139
+ '↻ ',
1140
+ t('visionAutoConverted'),
1141
+ )
1142
+ : null,
1143
+ // Hint whenever the probe cannot positively confirm image
1144
+ // input — false (confirmed text-only) or null (unresolved,
1145
+ // the common live case): /vision is harmless either way and
1146
+ // this chip is the only composer-surface affordance for it.
1147
+ vision !== null && vision.imageCapable !== true
1148
+ ? h(
1149
+ 'p',
1150
+ {
1151
+ 'data-dsh-switchman': 'vision-hint',
1152
+ style: {
1153
+ margin: '0',
1154
+ padding: '2px 0',
1155
+ color: 'var(--dsw-alias-label-tertiary)',
1156
+ fontSize: '12px',
1157
+ lineHeight: '1.6',
1158
+ },
1159
+ },
1160
+ '⚠ ',
1161
+ t(vision.poolConfigured ? 'visionHintUseVision' : 'visionHintNeedsPool'),
1162
+ )
1163
+ : null,
1164
+ );
1165
+ }
1166
+
1167
+ /** Home-sidebar panel icon (sidebar.panellist contract): the shell
1168
+ * row renderer passes { size, active } and colors the glyph via the
1169
+ * row's currentColor, so only size matters here. 16×16 stroke
1170
+ * glyph — one dispatch stem splitting into two routed lanes.
1171
+ * data-dsh-panel-entry is the documented L2 skins hook (the shell
1172
+ * row itself carries no per-entry marker). */
1173
+ function SwitchmanPanelIcon({ size }) {
1174
+ return h(
1175
+ 'svg',
1176
+ {
1177
+ 'data-dsh-panel-entry': 'dsh-switchman',
1178
+ viewBox: '0 0 16 16',
1179
+ width: size,
1180
+ height: size,
1181
+ fill: 'none',
1182
+ stroke: 'currentColor',
1183
+ strokeWidth: 1.3,
1184
+ strokeLinecap: 'round',
1185
+ strokeLinejoin: 'round',
1186
+ 'aria-hidden': true,
1187
+ },
1188
+ h('path', { d: 'M2 8h4.5' }),
1189
+ h('path', { d: 'M6.5 8 10.5 4H12' }),
1190
+ h('path', { d: 'M6.5 8l4 4H12' }),
1191
+ h('path', { d: 'M12.2 2.2 14 4l-1.8 1.8' }),
1192
+ h('path', { d: 'M12.2 10.2 14 12l-1.8 1.8' }),
1193
+ );
1194
+ }
1195
+
1196
+ /** Central-panel host for the sidebar entry: reuses the settings
1197
+ * page component unchanged (its {t, locale} props arrive as the
1198
+ * register inject() face). The main area hands the page a
1199
+ * definite-height, overflow-clipped frame with no gutter — the
1200
+ * page must own its scrolling (skill-explorer's .view/.tabBody
1201
+ * pattern): height:100% scroll owner outside, reading-width
1202
+ * centered column inside. */
1203
+ function SwitchmanPanelPage(props) {
1204
+ return h(
1205
+ 'div',
1206
+ {
1207
+ 'data-dsh-switchman': 'panel',
1208
+ style: {
1209
+ minWidth: 0,
1210
+ minHeight: 0,
1211
+ height: '100%',
1212
+ overflowY: 'auto',
1213
+ },
1214
+ },
1215
+ h(
1216
+ 'div',
1217
+ {
1218
+ style: {
1219
+ minWidth: 0,
1220
+ maxWidth: '860px',
1221
+ margin: '0 auto',
1222
+ padding: '16px 24px',
1223
+ boxSizing: 'border-box',
1224
+ },
1225
+ },
1226
+ h(SwitchmanSettingsPage, props),
1227
+ ),
1228
+ );
687
1229
  }
688
1230
 
689
1231
  // ------------------------------------------------------------------
@@ -834,7 +1376,7 @@ window.__ModuleLoader__.load({
834
1376
  cursor: 'pointer',
835
1377
  },
836
1378
  poolList: {
837
- maxHeight: '190px',
1379
+ maxHeight: '320px',
838
1380
  overflowY: 'auto',
839
1381
  display: 'grid',
840
1382
  gap: '2px',
@@ -867,6 +1409,10 @@ window.__ModuleLoader__.load({
867
1409
  color: 'var(--dsw-alias-label-tertiary)',
868
1410
  flex: 'none',
869
1411
  },
1412
+ unauthorizedMark: {
1413
+ color: 'var(--dsw-alias-state-error-primary)',
1414
+ flex: 'none',
1415
+ },
870
1416
  rankList: { display: 'grid', gap: '2px', marginTop: '8px' },
871
1417
  rankRow: {
872
1418
  display: 'grid',
@@ -885,9 +1431,12 @@ window.__ModuleLoader__.load({
885
1431
  color: 'var(--dsw-alias-label-primary)',
886
1432
  fontSize: '12.5px',
887
1433
  lineHeight: 1.6,
888
- overflow: 'hidden',
889
- textOverflow: 'ellipsis',
890
- whiteSpace: 'nowrap',
1434
+ // Wrap instead of ellipsize: provider/model pairs must stay
1435
+ // fully readable in the narrow panel (the hover title remains
1436
+ // a convenience, not the only way to read the full route).
1437
+ whiteSpace: 'normal',
1438
+ wordBreak: 'break-word',
1439
+ minWidth: 0,
891
1440
  },
892
1441
  rankActions: { display: 'flex', gap: '4px', flex: 'none' },
893
1442
  effortSelect: {
@@ -1002,6 +1551,8 @@ window.__ModuleLoader__.load({
1002
1551
  efforts,
1003
1552
  manual,
1004
1553
  editable,
1554
+ authorized,
1555
+ teamsMode,
1005
1556
  onToggle,
1006
1557
  onToggleManual,
1007
1558
  onSetEffort,
@@ -1011,6 +1562,26 @@ window.__ModuleLoader__.load({
1011
1562
  onClear,
1012
1563
  }) {
1013
1564
  const hasCandidates = rows.length > 0 || extras.length > 0;
1565
+ /** 授权漂移判定:该路由不在本会话 DSH 授权的子智能体模型内
1566
+ * (authorized === null = 未知,不标)。enabled === false 时
1567
+ * 一切显式指定都会被拒,所有已选路由都算漂移。仅团队模式
1568
+ * 显示:纯 subagent 模式不做显式路由注入,徽标无意义。 */
1569
+ const drift = (route) =>
1570
+ teamsMode === true &&
1571
+ authorized !== null &&
1572
+ (authorized.enabled === false ||
1573
+ !authorized.keys.has(routeKey(route)));
1574
+ /** 漂移行的 ⚠ 徽标(悬停/读屏文案见 unauthorizedBadge)。 */
1575
+ const unauthorizedBadge = () =>
1576
+ h(
1577
+ 'span',
1578
+ {
1579
+ style: STYLE.unauthorizedMark,
1580
+ title: t('unauthorizedBadge'),
1581
+ 'aria-label': t('unauthorizedBadge'),
1582
+ },
1583
+ '⚠',
1584
+ );
1014
1585
  /** 显示名查找:覆盖目录行与已存离架行。 */
1015
1586
  const nameByKey = new Map(
1016
1587
  [...rows, ...extras]
@@ -1134,6 +1705,7 @@ window.__ModuleLoader__.load({
1134
1705
  },
1135
1706
  nameByKey.get(routeKey(route)) ??
1136
1707
  `${route.provider} · ${route.model}`,
1708
+ drift(route) ? unauthorizedBadge() : null,
1137
1709
  ),
1138
1710
  effortSelect(route),
1139
1711
  h(
@@ -1299,6 +1871,9 @@ window.__ModuleLoader__.load({
1299
1871
  `${row.providerName} · ${row.modelName}`,
1300
1872
  ),
1301
1873
  ...(row.selected ? [effortSelect(row)] : []),
1874
+ ...(row.selected && drift(row)
1875
+ ? [unauthorizedBadge()]
1876
+ : []),
1302
1877
  ),
1303
1878
  ),
1304
1879
  extras.length > 0
@@ -1335,6 +1910,7 @@ window.__ModuleLoader__.load({
1335
1910
  { style: STYLE.unavailableMark },
1336
1911
  ` · ${t('catalogUnavailable')}`,
1337
1912
  ),
1913
+ drift(row) ? unauthorizedBadge() : null,
1338
1914
  ),
1339
1915
  ),
1340
1916
  )
@@ -1368,6 +1944,10 @@ window.__ModuleLoader__.load({
1368
1944
  const [saving, setSaving] = React.useState(false);
1369
1945
  const [failed, setFailed] = React.useState(false);
1370
1946
  const [conflicted, setConflicted] = React.useState(false);
1947
+ /** Last whitelist sync status from the Host config answer: null when
1948
+ * both teams switches are off (the Host omits the field), otherwise
1949
+ * { ok, count, at, error? }. */
1950
+ const [whitelistSync, setWhitelistSync] = React.useState(null);
1371
1951
 
1372
1952
  // Mirror the editable state for async save flows below (setState
1373
1953
  // alone is too late inside stale closures).
@@ -1377,6 +1957,20 @@ window.__ModuleLoader__.load({
1377
1957
  /** Values this page loaded — the optimistic-concurrency baseline the
1378
1958
  * Host route fences writes against. */
1379
1959
  const loadedRef = React.useRef(null);
1960
+ /** Unmount guard for the post-save sync-status re-reads. */
1961
+ const syncAlive = React.useRef(true);
1962
+ React.useEffect(() => () => {
1963
+ syncAlive.current = false;
1964
+ }, []);
1965
+
1966
+ /** Re-read the config answer for the whitelist sync status line: the
1967
+ * Host syncs after a successful write, so one immediate and one
1968
+ * delayed read bracket the async window. */
1969
+ const refreshWhitelistSync = React.useCallback(async () => {
1970
+ const response = await api.get('config');
1971
+ if (!syncAlive.current || !response.ok) return;
1972
+ setWhitelistSync(response.value?.whitelistSync ?? null);
1973
+ }, []);
1380
1974
 
1381
1975
  const assignDraft = (next) => {
1382
1976
  draftRef.current = next;
@@ -1392,6 +1986,9 @@ window.__ModuleLoader__.load({
1392
1986
  if (response.ok && response.value?.values !== undefined) {
1393
1987
  loadedRef.current = response.value.values;
1394
1988
  setForm({ status: 'ready', writable: true, values: response.value.values });
1989
+ // Whitelist sync status travels beside the values (null
1990
+ // unless both teams switches are on).
1991
+ setWhitelistSync(response.value.whitelistSync ?? null);
1395
1992
  } else setForm({ status: 'error', writable: false });
1396
1993
  })();
1397
1994
  return () => {
@@ -1432,6 +2029,25 @@ window.__ModuleLoader__.load({
1432
2029
  }, [loadCatalog]);
1433
2030
  const catalogIndex = indexCatalog(catalog.groups);
1434
2031
 
2032
+ // DSH-side child-model authorization (which routes this session may
2033
+ // name explicitly on subagent/workflow calls): loaded once on mount
2034
+ // beside the catalog. null (failed fetch or unreadable service)
2035
+ // means unknown — no ⚠ badges, no hint.
2036
+ const [authorized, setAuthorized] = React.useState(null);
2037
+ React.useEffect(() => {
2038
+ let alive = true;
2039
+ void (async () => {
2040
+ const response = await api.get('authorized');
2041
+ if (!alive) return;
2042
+ setAuthorized(
2043
+ response.ok ? parseAuthorized(response.value) : null,
2044
+ );
2045
+ })();
2046
+ return () => {
2047
+ alive = false;
2048
+ };
2049
+ }, []);
2050
+
1435
2051
  // Numeric watermark editing: one raw-text overlay per field so
1436
2052
  // partial input ('', '5', leading zeros) is editable without
1437
2053
  // corrupting the draft; parseable integers commit immediately,
@@ -1467,7 +2083,9 @@ window.__ModuleLoader__.load({
1467
2083
  !(values.wmSoftTokens < values.wmHardTokens) ||
1468
2084
  !(values.wmHardTokens < values.wmForceTokens);
1469
2085
  const invalid =
1470
- (customActive && values.langConversation.trim() === '') ||
2086
+ (values.langScope === 'global' &&
2087
+ customActive &&
2088
+ values.langConversation.trim() === '') ||
1471
2089
  anyNumberInvalid ||
1472
2090
  orderInvalid;
1473
2091
  const dirty = !sameValues(current, values);
@@ -1653,6 +2271,7 @@ window.__ModuleLoader__.load({
1653
2271
  if (form.status !== 'ready' || !form.writable || savingRef.current)
1654
2272
  return;
1655
2273
  const desired = {
2274
+ langScope: values.langScope,
1656
2275
  langConversation: values.langConversation.trim(),
1657
2276
  langComments: values.langComments,
1658
2277
  langDocs: values.langDocs,
@@ -1689,6 +2308,8 @@ window.__ModuleLoader__.load({
1689
2308
  wmDenyMode: values.wmDenyMode,
1690
2309
  wmAutoHandover: values.wmAutoHandover,
1691
2310
  wmSubagentCap: values.wmSubagentCap,
2311
+ teamsMode: values.teamsMode,
2312
+ syncWhitelist: values.syncWhitelist,
1692
2313
  };
1693
2314
  if (sameValues(current, desired)) {
1694
2315
  // Whitespace-only differences: nothing to write, close the draft.
@@ -1723,6 +2344,17 @@ window.__ModuleLoader__.load({
1723
2344
  writable: true,
1724
2345
  values: response.value.values,
1725
2346
  });
2347
+ // The write triggers the whitelist sync Host-side; the answer
2348
+ // may already carry fresh status, and the delayed re-read
2349
+ // covers the async tail.
2350
+ if (response.value?.whitelistSync !== undefined)
2351
+ setWhitelistSync(response.value.whitelistSync);
2352
+ if (desired.syncWhitelist === true) {
2353
+ void refreshWhitelistSync();
2354
+ setTimeout(() => {
2355
+ void refreshWhitelistSync();
2356
+ }, 2000);
2357
+ }
1726
2358
  assignDraft(null);
1727
2359
  setCustomArmed(false);
1728
2360
  clearNumText();
@@ -1770,6 +2402,9 @@ window.__ModuleLoader__.load({
1770
2402
  const conversationId = `${headingId}-conversation`;
1771
2403
  const commentsId = `${headingId}-comments`;
1772
2404
  const docsId = `${headingId}-docs`;
2405
+ const scopeId = `${headingId}-scope`;
2406
+ /** Project scope moves the three slots into per-project files. */
2407
+ const langLocked = values.langScope === 'project';
1773
2408
  const suggestion = suggestFromUiLocale(locale.getLocale().active);
1774
2409
 
1775
2410
  /** Status lines under one row's control. */
@@ -1805,12 +2440,12 @@ window.__ModuleLoader__.load({
1805
2440
  return lines;
1806
2441
  };
1807
2442
 
1808
- /** Shared select props (disabled while saving / read-only). */
1809
- const selectProps = (id, value, onChange) => ({
2443
+ /** Shared select props (disabled while saving / read-only / locked). */
2444
+ const selectProps = (id, value, onChange, locked = false) => ({
1810
2445
  id,
1811
2446
  value,
1812
2447
  onChange: (event) => onChange(event.target.value),
1813
- disabled: !editable,
2448
+ disabled: !editable || locked,
1814
2449
  style: STYLE.control,
1815
2450
  });
1816
2451
 
@@ -1825,6 +2460,7 @@ window.__ModuleLoader__.load({
1825
2460
  ? CUSTOM
1826
2461
  : values.langConversation,
1827
2462
  selectConversation,
2463
+ langLocked,
1828
2464
  ),
1829
2465
  ...languageOptions(t, { withUnset: 'ask', withCustom: true }),
1830
2466
  ),
@@ -1836,7 +2472,7 @@ window.__ModuleLoader__.load({
1836
2472
  edit('langConversation', event.target.value),
1837
2473
  placeholder: t('customLanguagePlaceholder'),
1838
2474
  maxLength: LANG_MAX,
1839
- disabled: !editable,
2475
+ disabled: !editable || langLocked,
1840
2476
  'aria-label': t('customLanguage'),
1841
2477
  style: STYLE.control,
1842
2478
  })
@@ -1864,9 +2500,30 @@ window.__ModuleLoader__.load({
1864
2500
  enforce: 'enforceHelpEnforce',
1865
2501
  };
1866
2502
  const display = rankDisplay(values, catalogIndex);
2503
+ // Teams mode (draft value, consistent with the rest of the page):
2504
+ // gates the ⚠ authorization badges/hint — plain subagent dispatch
2505
+ // names no explicit routes, so drift marks would be noise.
2506
+ const teamsMode = values.teamsMode === true;
2507
+ /** Authorization drift exists when at least one selected pool
2508
+ * route is outside the DSH-authorized child models (or the
2509
+ * whitelist is readable and explicit selection is disabled
2510
+ * while any pool is configured) — drives the section hint.
2511
+ * Teams mode only (see above). */
2512
+ const anyUnauthorized =
2513
+ teamsMode &&
2514
+ authorized !== null &&
2515
+ (authorized.enabled === false
2516
+ ? poolsSet > 0
2517
+ : POOL_FIELDS.some(({ field }) =>
2518
+ values[field].some(
2519
+ (route) => !authorized.keys.has(routeKey(route)),
2520
+ ),
2521
+ ));
1867
2522
 
1868
2523
  // ---- watermark section derived state ----
1869
2524
  const wmId = `${headingId}-wm`;
2525
+ // ---- teams section ids ----
2526
+ const teamsId = `${headingId}-teams`;
1870
2527
 
1871
2528
  /** One watermark number input: raw-text overlay over the draft
1872
2529
  * value (0 renders as the placeholder for the follow-force row),
@@ -1955,6 +2612,20 @@ window.__ModuleLoader__.load({
1955
2612
  )
1956
2613
  : null;
1957
2614
 
2615
+ /** Keys of every route configured across the six pools: the drift
2616
+ * badge is pool-scoped, so rank-only modelRank entries (in no
2617
+ * pool) never carry it. */
2618
+ const poolRouteKeys = new Set(
2619
+ POOL_FIELDS.flatMap(({ field }) => values[field].map(routeKey)),
2620
+ );
2621
+ /** Pool-scoped drift check for rank rows (same semantics as the
2622
+ * PoolCard drift helper, which is not in scope here). Teams mode
2623
+ * only, matching the pool cards. */
2624
+ const rankDrift = (route) =>
2625
+ teamsMode === true &&
2626
+ authorized !== null &&
2627
+ (authorized.enabled === false ||
2628
+ !authorized.keys.has(routeKey(route)));
1958
2629
  /** One ranking row: index, label, tier select, move/remove buttons. */
1959
2630
  const rankRow = (entry, index) => {
1960
2631
  const key = routeKey(entry);
@@ -1980,6 +2651,17 @@ window.__ModuleLoader__.load({
1980
2651
  ` · ${t('catalogUnavailable')}`,
1981
2652
  )
1982
2653
  : null,
2654
+ poolRouteKeys.has(key) && rankDrift(entry)
2655
+ ? h(
2656
+ 'span',
2657
+ {
2658
+ style: STYLE.unauthorizedMark,
2659
+ title: t('unauthorizedBadge'),
2660
+ 'aria-label': t('unauthorizedBadge'),
2661
+ },
2662
+ '⚠',
2663
+ )
2664
+ : null,
1983
2665
  ),
1984
2666
  h(
1985
2667
  'select',
@@ -2069,6 +2751,34 @@ window.__ModuleLoader__.load({
2069
2751
  h(
2070
2752
  'div',
2071
2753
  { style: STYLE.rows },
2754
+ h(
2755
+ SettingsRow,
2756
+ {
2757
+ key: 'scope',
2758
+ id: scopeId,
2759
+ label: t('langScopeLabel'),
2760
+ help: t('langScopeHelp'),
2761
+ control: h(
2762
+ 'select',
2763
+ selectProps(scopeId, values.langScope, (value) =>
2764
+ edit('langScope', value),
2765
+ ),
2766
+ h(
2767
+ 'option',
2768
+ { key: 'global', value: 'global' },
2769
+ t('langScopeGlobal'),
2770
+ ),
2771
+ h(
2772
+ 'option',
2773
+ { key: 'project', value: 'project' },
2774
+ t('langScopeProject'),
2775
+ ),
2776
+ ),
2777
+ lines: langLocked
2778
+ ? [{ text: t('langProjectNote'), invalid: false }]
2779
+ : [],
2780
+ },
2781
+ ),
2072
2782
  h(
2073
2783
  SettingsRow,
2074
2784
  {
@@ -2093,6 +2803,7 @@ window.__ModuleLoader__.load({
2093
2803
  commentsId,
2094
2804
  values.langComments,
2095
2805
  (value) => edit('langComments', value),
2806
+ langLocked,
2096
2807
  ),
2097
2808
  ...languageOptions(t, { withUnset: 'follow' }),
2098
2809
  ),
@@ -2112,6 +2823,7 @@ window.__ModuleLoader__.load({
2112
2823
  docsId,
2113
2824
  values.langDocs,
2114
2825
  (value) => edit('langDocs', value),
2826
+ langLocked,
2115
2827
  ),
2116
2828
  ...languageOptions(t, { withUnset: 'follow' }),
2117
2829
  ),
@@ -2146,6 +2858,13 @@ window.__ModuleLoader__.load({
2146
2858
  }),
2147
2859
  ),
2148
2860
  catalogNotice,
2861
+ anyUnauthorized
2862
+ ? h(
2863
+ 'p',
2864
+ { key: 'unauthorized', role: 'status', style: STYLE.progress },
2865
+ t('unauthorizedHint'),
2866
+ )
2867
+ : null,
2149
2868
  h(
2150
2869
  'div',
2151
2870
  { style: STYLE.poolGrid },
@@ -2164,6 +2883,8 @@ window.__ModuleLoader__.load({
2164
2883
  efforts: values[effortsField],
2165
2884
  manual: values[manualField],
2166
2885
  editable,
2886
+ authorized,
2887
+ teamsMode,
2167
2888
  onToggle: (meta) => {
2168
2889
  togglePool(field, meta);
2169
2890
  },
@@ -2333,6 +3054,102 @@ window.__ModuleLoader__.load({
2333
3054
  ),
2334
3055
  h('p', { style: STYLE.progress }, t('wmCommands')),
2335
3056
  ),
3057
+ h(
3058
+ 'section',
3059
+ {
3060
+ 'aria-labelledby': teamsId,
3061
+ style: { ...STYLE.section, ...STYLE.sectionDivider },
3062
+ },
3063
+ h(
3064
+ 'h3',
3065
+ { id: teamsId, style: STYLE.heading },
3066
+ t('teamsSectionTitle'),
3067
+ ),
3068
+ h(
3069
+ 'p',
3070
+ { style: STYLE.description },
3071
+ t('teamsSectionDescription'),
3072
+ ),
3073
+ h(
3074
+ 'div',
3075
+ { style: STYLE.rows },
3076
+ h(SettingsRow, {
3077
+ key: 'teamsMode',
3078
+ id: `${teamsId}-mode`,
3079
+ label: t('teamsModeLabel'),
3080
+ help: t('teamsModeHelp'),
3081
+ control: h('input', {
3082
+ type: 'checkbox',
3083
+ id: `${teamsId}-mode`,
3084
+ checked: values.teamsMode,
3085
+ onChange: () => {
3086
+ edit('teamsMode', !values.teamsMode);
3087
+ },
3088
+ disabled: !editable,
3089
+ style: STYLE.checkbox,
3090
+ }),
3091
+ lines: [],
3092
+ }),
3093
+ h(SettingsRow, {
3094
+ key: 'syncWhitelist',
3095
+ id: `${teamsId}-sync`,
3096
+ label: t('syncWhitelistLabel'),
3097
+ help: t('syncWhitelistHelp'),
3098
+ control: h('input', {
3099
+ type: 'checkbox',
3100
+ id: `${teamsId}-sync`,
3101
+ checked: values.syncWhitelist,
3102
+ onChange: () => {
3103
+ edit('syncWhitelist', !values.syncWhitelist);
3104
+ },
3105
+ disabled: !editable,
3106
+ style: STYLE.checkbox,
3107
+ }),
3108
+ // Status line reads the Host-reported sync state;
3109
+ // visible whenever the (independent) sync switch
3110
+ // is on — the only shape the Host reports for.
3111
+ lines:
3112
+ values.syncWhitelist === true
3113
+ ? [
3114
+ whitelistSync == null
3115
+ ? {
3116
+ text: t('whitelistSyncPending'),
3117
+ invalid: false,
3118
+ }
3119
+ : whitelistSync.ok === true
3120
+ ? {
3121
+ text: t('whitelistSyncOk', {
3122
+ count: whitelistSync.count ?? 0,
3123
+ time:
3124
+ typeof whitelistSync.at ===
3125
+ 'number' &&
3126
+ whitelistSync.at > 0
3127
+ ? new Date(
3128
+ whitelistSync.at,
3129
+ ).toLocaleString()
3130
+ : '—',
3131
+ }),
3132
+ invalid: false,
3133
+ }
3134
+ : {
3135
+ text: t('whitelistSyncError', {
3136
+ error:
3137
+ typeof whitelistSync.error ===
3138
+ 'string' &&
3139
+ whitelistSync.error !== ''
3140
+ ? whitelistSync.error
3141
+ : 'unknown',
3142
+ }),
3143
+ invalid: true,
3144
+ },
3145
+ ]
3146
+ : [],
3147
+ }),
3148
+ ),
3149
+ teamsMode
3150
+ ? h('p', { style: STYLE.progress }, t('whitelistSyncNote'))
3151
+ : null,
3152
+ ),
2336
3153
  h(
2337
3154
  'div',
2338
3155
  { style: STYLE.footer },
@@ -2398,6 +3215,19 @@ window.__ModuleLoader__.load({
2398
3215
  apply(ctx) {
2399
3216
  ctx.effect(() => ctx.locale.register(NS, DICTS));
2400
3217
  const t = ctx.locale.bind(NS);
3218
+ // 报告当前生效的 UI 语言给 host 半场(决定语言询问指引的
3219
+ // 提问语言;fire-and-forget,失败静默——host 会退回
3220
+ // settings 的 locale.preference,再退回英文)。3 秒后补报
3221
+ // 一次,覆盖 host 路由晚于本页就绪的启动窗口。
3222
+ const reportUiLocale = () =>
3223
+ void api.post('ui-locale', {
3224
+ locale: ctx.locale?.getLocale?.().active ?? '',
3225
+ });
3226
+ reportUiLocale();
3227
+ setTimeout(reportUiLocale, 3000);
3228
+ // The session scope hands the badge the standard kit: the open
3229
+ // session's id and its live projection face (the model line),
3230
+ // plus the sessions service for the un-retained fallback.
2401
3231
  ctx.slots.inject('conversation.session.header.actions', () =>
2402
3232
  ctx.slots.register(
2403
3233
  {
@@ -2405,7 +3235,60 @@ window.__ModuleLoader__.load({
2405
3235
  id: 'dsh-switchman',
2406
3236
  order: -9,
2407
3237
  },
2408
- () => h(SwitchmanBadge, { locale: ctx.locale, t }),
3238
+ (kit) =>
3239
+ h(SwitchmanBadge, {
3240
+ ...kit,
3241
+ locale: ctx.locale,
3242
+ t,
3243
+ }),
3244
+ ),
3245
+ );
3246
+ // Home left-sidebar entry + central panel: the same slot pair
3247
+ // the shipped Skills Center row uses. The shell owns the row
3248
+ // button, click-to-switch, active highlight, and collapsed
3249
+ // tooltip. The sidebar id and the main key must match, and
3250
+ // the inject() face becomes the page component's props
3251
+ // ({t, locale} — SwitchmanPanelPage forwards them verbatim).
3252
+ // order 31 sits directly under the Skills Center row
3253
+ // (skill-explorer 30; plugins 0, schedules 10).
3254
+ ctx.slots.inject('sidebar.panellist', () =>
3255
+ ctx.slots.register(
3256
+ {
3257
+ name: 'sidebar.panellist',
3258
+ id: 'dsh-switchman',
3259
+ order: 31,
3260
+ label: () => t('panelLabel'),
3261
+ locale: NS,
3262
+ },
3263
+ SwitchmanPanelIcon,
3264
+ ),
3265
+ );
3266
+ ctx.slots.inject('main', () =>
3267
+ ctx.slots.register(
3268
+ {
3269
+ name: 'main',
3270
+ key: 'dsh-switchman',
3271
+ inject: () => ({ t, locale: ctx.locale }),
3272
+ },
3273
+ SwitchmanPanelPage,
3274
+ ),
3275
+ );
3276
+ // Composer-adjacent vision entry (conversation.input.dock: the
3277
+ // full-width list ABOVE the composer card): guidance chip while
3278
+ // the current session's model cannot read images, plus reactive
3279
+ // auto-conversion of refused image sends into /vision submits.
3280
+ // The session scope automatically provides
3281
+ // inputActions/useInput/useSession to the registered
3282
+ // component; the wrapper forwards that standard kit through
3283
+ // alongside the closure-bound t.
3284
+ ctx.slots.inject('conversation.input.dock', () =>
3285
+ ctx.slots.register(
3286
+ {
3287
+ name: 'conversation.input.dock',
3288
+ id: 'dsh-switchman-vision-hint',
3289
+ order: 90,
3290
+ },
3291
+ (kit) => h(VisionDockEntry, { ...kit, t }),
2409
3292
  ),
2410
3293
  );
2411
3294
  // Settings page: one settings.section entry. The restricted