@mars.liu/dsh-cybernetics 0.7.8 → 0.8.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/lib/client.js CHANGED
@@ -1,8 +1,12 @@
1
1
  // dsh-cybernetics client module: the「目标 / Goal」conversation tab, the
2
- //「控制」tab, and (v0.7.2+, fixed in v0.7.3) the allGoalsMode settings card
3
- // seated on the official Plugins page (plugins.item / plugins.row.config /
4
- // plugins.bundle.config, bound to the row's ConfigForm in either of the two
5
- // shapes the seats deliver — volatile writes stay live without a restart).
2
+ //「控制」tab, and (v0.7.2+, fixed in v0.7.3, full volatile coverage in v0.8.1,
3
+ // triageAgentModel added in v0.8.2) the settings card seated on the official
4
+ // Plugins page (plugins.item / plugins.row.config / plugins.bundle.config,
5
+ // bound to the row's ConfigForm in either of the two shapes the seats deliver
6
+ // — volatile writes stay live without a restart). The card is the ONLY GUI
7
+ // surface for the plugin's seven volatile fields: the host Plugins page draws
8
+ // no fields of its own and dsh-client-ui-settings ships the configForms
9
+ // service, not a renderer.
6
10
  // Rendered from the session's `goal` projection (dsh-goal), pushed live by the
7
11
  // host — no plugin HTTP route needed. Hand-written ModuleLoader wrapper, same
8
12
  // format the host bundler emits (see dsh-context / dsh-maze client.js).
@@ -82,6 +86,35 @@ window.__ModuleLoader__.load({
82
86
  // surfaces the host's exact RemoteError message (the shared
83
87
  // ConfigFormController swallows it into a bare `false`).
84
88
  'settings.saveFailedDetail': '保存未生效,宿主拒绝原因:{message}',
89
+ // v0.8.1: the remaining five volatile fields. The card is the ONLY
90
+ // GUI surface for them (see the card region comment): the host has
91
+ // no generic schema-driven field renderer, so every field the card
92
+ // does not draw is invisible everywhere.
93
+ 'settings.taskAgentModel': 'Task agent 模型',
94
+ 'settings.verifyAgentModel': 'Verify agent 模型',
95
+ // v0.8.3: the three model fields are dropdown-backed (datalist over the
96
+ // host's live model catalog, remote.session.modelCatalog) with free
97
+ // manual entry preserved — the datalist input accepts any text.
98
+ 'settings.modelFieldHint': '可从下拉选择当前系统模型(显示 provider · provider/model),也可手工输入「provider/model」或裸模型名;留空 = 与 session agent 一致(继承)。回车或移开焦点即保存。',
99
+ // v0.8.3: configured value no longer in the live catalog = deleted
100
+ // subscription. Warn + one-click clear; NEVER auto-unset (the value
101
+ // may be valid again after resubscribing, and silent rewrites of
102
+ // user config are a side effect) — the runtime already falls back to
103
+ // the session agent model per v0.8.0, so the warning is advisory.
104
+ 'settings.modelUnavailable': '该模型已不在当前系统模型列表中,运行时将回退到 session agent 模型。',
105
+ 'settings.modelClear': '清除',
106
+ // v0.8.2: the triage role's flat model field — triage reads the
107
+ // task/verify results and emits the round's convergence decision
108
+ // (met/continue/human-input/...); quick rounds never invoke it.
109
+ 'settings.triageAgentModel': 'Triage agent 模型',
110
+ 'settings.triageModelHint': '可从下拉选择当前系统模型,也可手工输入「provider/model」或裸模型名;留空 = 与 session agent 一致(继承)。triage 角色读 task/verify 结果输出本轮收敛决策(met/continue/human-input 等),quick 轮不启用。回车或移开焦点即保存。',
111
+ 'settings.roundsPerSubgoal': '子目标轮数上限',
112
+ 'settings.roundsPerSubgoalHint': '同一子目标的最大连续轮数(整数 ≥1,缺省 3);留空恢复默认。回车或移开焦点即保存。',
113
+ 'settings.maxConcurrentRounds': '并行轮数上限',
114
+ 'settings.maxConcurrentRoundsHint': '全局并行轮数上限(整数 ≥0,缺省 0 = 不限);留空恢复默认。回车或移开焦点即保存。',
115
+ // v0.8.4: roles 高级配置不再上卡片(手工编辑 JSON 对用户不友善)——
116
+ // 由 agent 指导用户直接编辑 profile 配置 yaml(见 README)。
117
+ 'settings.invalidNumber': '请输入符合范围的整数,或留空恢复默认。',
85
118
  };
86
119
  const en = {
87
120
  'view.goal': 'Goal',
@@ -133,6 +166,20 @@ window.__ModuleLoader__.load({
133
166
  'settings.saving': 'Saving…',
134
167
  'settings.saveFailed': 'Save did not take effect (the host refused the write).',
135
168
  'settings.saveFailedDetail': 'Save did not take effect — host refusal: {message}',
169
+ // v0.8.1: the remaining five volatile fields (see zh for the rationale).
170
+ 'settings.taskAgentModel': 'Task agent model',
171
+ 'settings.verifyAgentModel': 'Verify agent model',
172
+ 'settings.modelFieldHint': 'Pick from the dropdown of currently available system models (shown as provider · provider/model), or type "provider/model" or a bare model name; empty = inherit the session agent. Enter or blur saves.',
173
+ 'settings.modelUnavailable': 'This model is no longer in the system model list; at runtime the round falls back to the session agent model.',
174
+ 'settings.modelClear': 'Clear',
175
+ // v0.8.2: the triage role's flat model field (see zh for the rationale).
176
+ 'settings.triageAgentModel': 'Triage agent model',
177
+ 'settings.triageModelHint': 'Pick from the dropdown of available system models, or type "provider/model" or a bare model name; empty = inherit the session agent. The triage role reads the task/verify results and emits the round\'s convergence decision (met/continue/human-input/...); quick rounds never invoke it. Enter or blur saves.',
178
+ 'settings.roundsPerSubgoal': 'Rounds per subgoal',
179
+ 'settings.roundsPerSubgoalHint': 'Max consecutive rounds on one subgoal (integer ≥1, default 3); empty restores the default. Enter or blur saves.',
180
+ 'settings.maxConcurrentRounds': 'Concurrent round cap',
181
+ 'settings.maxConcurrentRoundsHint': 'Global cap on rounds in flight (integer ≥0, default 0 = unlimited); empty restores the default. Enter or blur saves.',
182
+ 'settings.invalidNumber': 'Enter an integer in range, or leave blank to restore the default.',
136
183
  };
137
184
  //#endregion
138
185
 
@@ -446,7 +493,7 @@ window.__ModuleLoader__.load({
446
493
  const noopSubscribe = () => () => {};
447
494
 
448
495
  var settingsStyles = {
449
- card: { display: 'flex', flexDirection: 'column', gap: 6, padding: '12px 0' },
496
+ card: { display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 0' },
450
497
  row: { display: 'flex', alignItems: 'center', gap: 12 },
451
498
  main: { display: 'flex', flexDirection: 'column', gap: 2, flex: 1, minWidth: 0 },
452
499
  title: { fontSize: 13, fontWeight: 600 },
@@ -465,15 +512,123 @@ window.__ModuleLoader__.load({
465
512
  knobOn: { left: 20, background: '#fff' },
466
513
  knobOff: { left: 2, background: 'currentColor' },
467
514
  unavailable: { fontSize: 12, opacity: 0.6 },
515
+ // v0.8.1: editable volatile fields (text / number inputs).
516
+ field: { display: 'flex', flexDirection: 'column', gap: 4, paddingTop: 6, borderTop: '1px solid rgba(127,127,127,0.18)' },
517
+ input: {
518
+ font: 'inherit', fontSize: 12, color: 'inherit', maxWidth: 320,
519
+ background: 'transparent', border: '1px solid rgba(127,127,127,0.4)',
520
+ borderRadius: 4, padding: '3px 8px', outline: 'none',
521
+ },
522
+ inputInvalid: { borderColor: '#d29922' },
523
+ invalid: { fontSize: 11, color: '#d29922' },
468
524
  };
469
525
 
526
+ // v0.8.1: the card renders ALL volatile fields, not just the
527
+ // allGoalsMode toggle (v0.8.2 adds triageAgentModel → seven). Verified
528
+ // against the running host's sources (2026-11-04): the Plugins page
529
+ // detail view (ItemDetail in @deepseek-ai/dsh-client-ui-plugin-manager
530
+ // lib/client.js) renders a plugin's configuration ONLY through its slot
531
+ // cards — the page itself draws no fields;
532
+ // @deepseek-ai/dsh-client-ui-settings ships the configForms SERVICE
533
+ // (transport + volatileForm serve/write gates) but no generic field
534
+ // renderer; dsh-client-ui-settings-plugins is a read-only inventory
535
+ // shell ("Configuring a built-in plugin happens on the sidebar's
536
+ // Plugins page, where each official plugin's own companion package
537
+ // registers its page"). Official plugins each draw their own card
538
+ // (agent-loop / web-search / …). So a volatile field this card does not
539
+ // render is editable NOWHERE in the GUI — that was the v0.7.9–v0.8.0
540
+ // panel gap. Field types follow the schema (src/index.ts Config):
541
+ // allGoalsMode boolean toggle; taskAgentModel / verifyAgentModel /
542
+ // triageAgentModel text ('' = inherit); roundsPerSubgoal /
543
+ // maxConcurrentRounds integers (≥1 / ≥0; blank restores the default via
544
+ // an `unset` op). v0.8.4: roles is no longer on the card — hand-editing
545
+ // a JSON object in the GUI is unfriendly; roles became non-volatile and
546
+ // is configured directly in the profile yaml (agent-guided, README).
547
+ // The card's field list is back to six:
548
+ // allGoalsMode / taskAgentModel / verifyAgentModel / triageAgentModel /
549
+ // roundsPerSubgoal / maxConcurrentRounds.
550
+ const SETTINGS_FIELDS = [
551
+ // v0.8.3: the three model fields are kind 'model' — datalist-backed
552
+ // dropdown over the live host catalog, manual entry still accepted
553
+ // (a datalist input is a free text input); invalid configured values
554
+ // render the unavailable warning + a one-click clear.
555
+ { field: 'taskAgentModel', labelKey: 'settings.taskAgentModel', hintKey: 'settings.modelFieldHint', kind: 'model' },
556
+ { field: 'verifyAgentModel', labelKey: 'settings.verifyAgentModel', hintKey: 'settings.modelFieldHint', kind: 'model' },
557
+ { field: 'triageAgentModel', labelKey: 'settings.triageAgentModel', hintKey: 'settings.triageModelHint', kind: 'model' },
558
+ { field: 'roundsPerSubgoal', labelKey: 'settings.roundsPerSubgoal', hintKey: 'settings.roundsPerSubgoalHint', kind: 'number', min: 1 },
559
+ { field: 'maxConcurrentRounds', labelKey: 'settings.maxConcurrentRounds', hintKey: 'settings.maxConcurrentRoundsHint', kind: 'number', min: 0 },
560
+ ];
561
+ /** Sentinel for "blank input = restore the schema default" (an unset op). */
562
+ const UNSET = {};
563
+ //#region v0.8.3 model catalog store (dropdown data + invalid-model detection)
564
+ // Host model directory, read the same way the official 子智能体 settings
565
+ // page does (@deepseek-ai/dsh-client-ui-settings-subagent): the
566
+ // `remote.session` namespace answers `modelCatalog()` with
567
+ // { ok, value: { groups: [{id, name, models: [{id, name}]}], failures } }
568
+ // — every currently routable model, grouped by provider. Provider
569
+ // invalidations arrive as `llm/adapters-updated` on ctx.remote. OPTIONAL
570
+ // everywhere: a host without the namespace leaves the store absent, the
571
+ // dropdown empty and the text entry intact — no crash, no warnings.
572
+ function createModelCatalogStore() {
573
+ let snap = { status: 'idle', options: [] };
574
+ const subs = new Set();
575
+ return {
576
+ subscribe(cb) { subs.add(cb); return () => subs.delete(cb); },
577
+ getSnapshot() { return snap; },
578
+ set(next) { snap = next; for (const cb of subs) cb(); },
579
+ load: async (session) => {
580
+ snap = { status: 'loading', options: snap.options };
581
+ for (const cb of subs) cb();
582
+ try {
583
+ const response = await session.modelCatalog();
584
+ if (response && response.ok === true && response.value && Array.isArray(response.value.groups)) {
585
+ const options = [];
586
+ for (const group of response.value.groups) {
587
+ for (const model of (group.models ?? [])) {
588
+ options.push({
589
+ value: group.id + '/' + model.id,
590
+ provider: group.id,
591
+ model: model.id,
592
+ label: (group.name ?? group.id) + ' · ' + group.id + '/' + model.id,
593
+ });
594
+ }
595
+ }
596
+ snap = { status: 'ready', options };
597
+ } else {
598
+ snap = { status: 'error', options: [] };
599
+ }
600
+ } catch {
601
+ snap = { status: 'error', options: [] };
602
+ }
603
+ for (const cb of subs) cb();
604
+ },
605
+ };
606
+ }
607
+ /**
608
+ * Whether a configured model value still resolves against the live
609
+ * catalog. Accepts the same shapes the runtime does (parseAgentModelSpec):
610
+ * `provider/model` (exact option match) or a bare model name (matches any
611
+ * provider's model id). An empty value or an absent/failed catalog never
612
+ * flags — the warning must be provable, not a guess.
613
+ */
614
+ function isConfiguredModelAvailable(value, catalogSnap) {
615
+ const text = (value ?? '').trim();
616
+ if (text === '') return true;
617
+ if (!catalogSnap || catalogSnap.status !== 'ready' || !Array.isArray(catalogSnap.options) || catalogSnap.options.length === 0) return true;
618
+ if (text.indexOf('/') >= 0) {
619
+ return catalogSnap.options.some((opt) => opt.value === text);
620
+ }
621
+ return catalogSnap.options.some((opt) => opt.model === text);
622
+ }
623
+ //#endregion
624
+
470
625
  /**
471
- * One boolean settings row; shared by all three seats. Two form shapes
472
- * reach it, and the card must speak both:
626
+ * The settings card; shared by all three seats. Two form shapes reach
627
+ * it, and the card must speak both:
473
628
  * - the LIVE ConfigForm this module injects (ctx.configForms.get):
474
- * getSnapshot / subscribe / set — the only shape on the
475
- * plugins.bundle.config page and in summary rows, whose owners pass
476
- * no form of their own;
629
+ * getSnapshot / subscribe / set / unset / mutate — the only shape on
630
+ * the plugins.bundle.config page and in summary rows, whose owners
631
+ * pass no form of their own;
477
632
  * - the HOST's ConfigPageForm { state, mutate }: the plugins.item and
478
633
  * plugins.row.config detail pages compute it from the same shared
479
634
  * mirror (configForms.get(id).getSnapshot()/mutate) and pass it as
@@ -516,44 +671,71 @@ window.__ModuleLoader__.load({
516
671
  // `false` = no failure; `true` = failure without a host message;
517
672
  // a string = the host's exact refusal (RemoteError.message).
518
673
  const [failed, setFailed] = react.useState(false);
674
+ // v0.8.1: per-field edit drafts (field → text; absent = untouched,
675
+ // the control shows the snapshot value) and the field whose current
676
+ // draft failed local validation ('' = none). A failed SAVE keeps
677
+ // the draft for correction; an accepted write clears it.
678
+ const [drafts, setDrafts] = react.useState({});
679
+ const [invalid, setInvalid] = react.useState('');
680
+ // v0.8.3: the live model catalog face (optional prop; absent on hosts
681
+ // without remote.session — the dropdown then renders empty and no
682
+ // availability warning is ever shown).
683
+ const catalog = props.catalog;
684
+ const catalogSnap = react.useSyncExternalStore(
685
+ catalog ? (cb) => catalog.subscribe(cb) : noopSubscribe,
686
+ catalog ? () => catalog.getSnapshot() : () => NO_FORM_SNAP,
687
+ );
688
+ const catalogOptions = catalogSnap && Array.isArray(catalogSnap.options) ? catalogSnap.options : [];
519
689
  const on = snap.status === 'ready' && snap.value != null && snap.value.allGoalsMode === true;
690
+ const cfg = snap.status === 'ready' && snap.value !== null && typeof snap.value === 'object' ? snap.value : {};
520
691
  const disabled = (live === undefined && host === undefined) || snap.status !== 'ready' || snap.writable === false || saving;
692
+ const setDraft = (field, text) => setDrafts((d) => ({ ...d, [field]: text }));
693
+ const clearDraft = (field) => setDrafts((d) => {
694
+ if (!(field in d)) return d;
695
+ const next = { ...d };
696
+ delete next[field];
697
+ return next;
698
+ });
521
699
 
522
- const toggle = () => {
700
+ // One write for any volatile field; the toggle is
701
+ // writeField('allGoalsMode', !on). Both write paths land in the same
702
+ // host mutation: ConfigForm.set/unset are one-field sugar over
703
+ // ConfigForm.mutate, and the host's ConfigPageForm.mutate IS that
704
+ // form's mutate (the expected revision carried explicitly). The
705
+ // host commits the write into the running plugin instance
706
+ // (volatile) and the next snapshot / page render pushes the
707
+ // accepted value back here.
708
+ // Failure policy (v0.7.4): the transport can REFUSE (settles
709
+ // false) or stall (the observed "保存中" hang). Neither is
710
+ // silent: a refused write shows saveFailed for a few seconds,
711
+ // and a stalled write releases the controls after 15 s with the
712
+ // same hint — every control always returns to the snapshot value,
713
+ // which is the only state the host confirmed.
714
+ // v0.7.6: a refused write is retried ONCE through the direct
715
+ // settings transport (props.directMutate, no expectedRevision).
716
+ // Same落点 (user patch row via configEditor), unconditional
717
+ // revision — a stale-revision conflict therefore heals, and any
718
+ // remaining refusal reports its host message verbatim.
719
+ const writeField = (field, value) => {
523
720
  if (disabled) return;
524
721
  setSaving(true);
525
722
  setFailed(false);
526
- // Both write paths land in the same host mutation: ConfigForm.set
527
- // is one-field sugar over ConfigForm.mutate, and the host's
528
- // ConfigPageForm.mutate IS that form's mutate (the expected
529
- // revision carried explicitly). The host commits the write into
530
- // the running plugin instance (volatile) and the next snapshot /
531
- // page render pushes the accepted value back here.
532
- // Failure policy (v0.7.4): the transport can REFUSE (settles
533
- // false) or stall (the observed "保存中" hang). Neither is
534
- // silent: a refused write shows saveFailed for a few seconds,
535
- // and a stalled write releases the switch after 15 s with the
536
- // same hint — the switch always returns to the snapshot value,
537
- // which is the only state the host confirmed.
538
- // v0.7.6: a refused write is retried ONCE through the direct
539
- // settings transport (props.directMutate, no expectedRevision).
540
- // Same落点 (user patch row via configEditor), unconditional
541
- // revision — a stale-revision conflict therefore heals, and any
542
- // remaining refusal reports its host message verbatim.
723
+ setInvalid('');
543
724
  let settled = false;
544
725
  const finish = (ok, detail) => {
545
726
  if (settled) return;
546
727
  settled = true;
547
728
  setSaving(false);
548
- if (!ok) {
549
- setFailed(detail || true);
550
- setTimeout(() => setFailed(false), 6000);
551
- }
729
+ if (ok) { clearDraft(field); return; }
730
+ setFailed(detail || true);
731
+ setTimeout(() => setFailed(false), 6000);
552
732
  };
553
733
  const guard = setTimeout(() => finish(false), 15000);
554
- const op = { op: 'set', path: ['allGoalsMode'], value: !on };
734
+ const op = value === UNSET
735
+ ? { op: 'unset', path: [field] }
736
+ : { op: 'set', path: [field], value };
555
737
  const write = live !== undefined
556
- ? live.set('allGoalsMode', !on)
738
+ ? (value === UNSET ? live.unset(field) : live.set(field, value))
557
739
  : host.mutate([op], snap.revision);
558
740
  Promise.resolve(write)
559
741
  .then(async (accepted) => {
@@ -572,11 +754,98 @@ window.__ModuleLoader__.load({
572
754
  })
573
755
  .catch((error) => { clearTimeout(guard); finish(false, error && error.message); });
574
756
  };
757
+ const toggle = () => writeField('allGoalsMode', !on);
758
+
759
+ // Text/number commit (Enter or blur): an untouched field never
760
+ // writes; a draft equal to the snapshot just clears; a blank draft
761
+ // restores the schema default through an `unset` op; a number
762
+ // outside its range flags the field instead of writing.
763
+ const commitField = (spec) => {
764
+ const draft = drafts[spec.field];
765
+ if (draft === undefined) return;
766
+ const current = cfg[spec.field];
767
+ const text = draft.trim();
768
+ if (text === (current === undefined || current === null ? '' : String(current))) {
769
+ clearDraft(spec.field);
770
+ setInvalid('');
771
+ return;
772
+ }
773
+ if (text === '') { writeField(spec.field, UNSET); return; }
774
+ if (spec.kind === 'number') {
775
+ const n = Number(text);
776
+ if (!Number.isInteger(n) || n < spec.min) { setInvalid(spec.field); return; }
777
+ writeField(spec.field, n);
778
+ return;
779
+ }
780
+ writeField(spec.field, text);
781
+ };
575
782
 
576
783
  if (view === 'summary') {
577
784
  return jsx.jsx('span', { children: snap.status === 'ready' ? t(on ? 'settings.summaryOn' : 'settings.summaryOff') : t('settings.unavailable') });
578
785
  }
579
786
 
787
+ const fieldRow = (spec) => {
788
+ // v0.8.3: availability verdict for the configured (snapshot) value —
789
+ // drafts are uncommitted text and never warn. Only a loaded catalog
790
+ // can flag; an empty/failed catalog keeps every value "available".
791
+ const configuredValue = cfg[spec.field] === undefined || cfg[spec.field] === null ? '' : String(cfg[spec.field]);
792
+ const modelUnavailable = spec.kind === 'model'
793
+ && !isConfiguredModelAvailable(configuredValue, catalogSnap);
794
+ const listId = 'cybernetics-models-' + spec.field;
795
+ return jsx.jsxs('div', { style: settingsStyles.field, children: [
796
+ jsx.jsx('div', { style: settingsStyles.title, children: t(spec.labelKey) }),
797
+ jsx.jsx('div', { style: settingsStyles.desc, children: t(spec.hintKey) }),
798
+ snap.status === 'ready'
799
+ ? jsx.jsxs('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 }, children: [
800
+ jsx.jsx('input', {
801
+ type: 'text',
802
+ // datalist: native dropdown over the catalog options; the
803
+ // input stays a free text field, so manual entry is the
804
+ // built-in fallback (and values off-catalog still commit).
805
+ list: spec.kind === 'model' && catalogOptions.length > 0 ? listId : undefined,
806
+ inputMode: spec.kind === 'number' ? 'numeric' : undefined,
807
+ 'aria-label': t(spec.labelKey),
808
+ 'aria-invalid': invalid === spec.field || undefined,
809
+ disabled,
810
+ value: drafts[spec.field] !== undefined
811
+ ? drafts[spec.field]
812
+ : configuredValue,
813
+ onChange: (e) => setDraft(spec.field, e && e.target ? String(e.target.value) : ''),
814
+ onKeyDown: (e) => {
815
+ if (!e) return;
816
+ if (e.key === 'Enter') commitField(spec);
817
+ if (e.key === 'Escape') { clearDraft(spec.field); setInvalid(''); }
818
+ },
819
+ onBlur: () => commitField(spec),
820
+ style: { ...settingsStyles.input, ...(invalid === spec.field ? settingsStyles.inputInvalid : {}) },
821
+ }),
822
+ spec.kind === 'model' && catalogOptions.length > 0
823
+ ? jsx.jsx('datalist', { id: listId, children: catalogOptions.map((opt) =>
824
+ jsx.jsx('option', { value: opt.value, label: opt.label ?? opt.value }, opt.value)) })
825
+ : null,
826
+ ] })
827
+ : null,
828
+ modelUnavailable
829
+ ? jsx.jsxs('div', { style: { ...settingsStyles.invalid, color: '#d29922', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }, children: [
830
+ jsx.jsx('span', { children: t('settings.modelUnavailable') }),
831
+ // One-click clear = an explicit user action writing an unset
832
+ // op (same writeField path as blanking the field) — never a
833
+ // silent auto-unset of stored config (recorded trade-off in
834
+ // docs/CHANGELOG.md v0.8.3).
835
+ jsx.jsx('button', {
836
+ type: 'button',
837
+ 'aria-label': t('settings.modelClear') + ' ' + t(spec.labelKey),
838
+ disabled,
839
+ onClick: () => writeField(spec.field, UNSET),
840
+ style: { ...settingsStyles.apply, alignSelf: 'auto' },
841
+ children: t('settings.modelClear'),
842
+ }),
843
+ ] })
844
+ : null,
845
+ invalid === spec.field ? jsx.jsx('div', { style: settingsStyles.invalid, children: t('settings.invalidNumber') }) : null,
846
+ ] }, spec.field);
847
+ };
848
+
580
849
  return jsx.jsxs('div', { style: settingsStyles.card, children: [
581
850
  jsx.jsxs('div', { style: settingsStyles.row, children: [
582
851
  jsx.jsxs('div', { style: settingsStyles.main, children: [
@@ -592,6 +861,7 @@ window.__ModuleLoader__.load({
592
861
  })
593
862
  : null,
594
863
  ] }),
864
+ ...SETTINGS_FIELDS.map(fieldRow),
595
865
  snap.status !== 'ready' ? jsx.jsx('div', { style: settingsStyles.unavailable, children: t('settings.unavailable') }) : null,
596
866
  saving ? jsx.jsx('div', { style: settingsStyles.unavailable, children: t('settings.saving') }) : null,
597
867
  !saving && failed ? jsx.jsx('div', { style: { ...settingsStyles.unavailable, color: '#d29922' }, children: failed === true ? t('settings.saveFailed') : t('settings.saveFailedDetail', { message: failed }) }) : null,
@@ -612,7 +882,12 @@ window.__ModuleLoader__.load({
612
882
  // fallback. Optional at runtime (guarded in apply): a host without
613
883
  // the namespace parks this client module exactly as it would
614
884
  // ui-settings itself — the web profile always serves it.
615
- 'remote', 'remote.settings'];
885
+ 'remote', 'remote.settings',
886
+ // v0.8.3: the settings card's model dropdown reads the host model
887
+ // catalog through the session namespace (same service the official
888
+ // ui-settings-subagent page declares). Guarded at runtime: absent →
889
+ // no dropdown, no warnings, text entry as before.
890
+ 'remote.session'];
616
891
 
617
892
  function apply(ctx) {
618
893
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-cybernetics: dictionaries');
@@ -641,7 +916,28 @@ window.__ModuleLoader__.load({
641
916
  } catch {
642
917
  settingsDirectMutate = undefined;
643
918
  }
644
- const settingsInject = () => ({ form: settingsForm, directMutate: settingsDirectMutate });
919
+ // v0.8.3: the live model catalog for the settings card's dropdown +
920
+ // invalid-model warnings. OPTIONAL: a host without the remote.session
921
+ // namespace (or without modelCatalog) leaves it undefined — the card
922
+ // renders text-only fields exactly as pre-v0.8.3, no crash, no
923
+ // false warnings. Refresh on `llm/adapters-updated` (the same
924
+ // invalidation the 子智能体 settings page listens to), so a model
925
+ // deleted/added in the model settings updates the dropdown live.
926
+ let catalogStore;
927
+ try {
928
+ if (ctx.remote && ctx.remote.session && typeof ctx.remote.session.modelCatalog === 'function') {
929
+ catalogStore = createModelCatalogStore();
930
+ ctx.effect(() => {
931
+ catalogStore.load(ctx.remote.session);
932
+ if (ctx.remote.$on && typeof ctx.remote.$on === 'function') {
933
+ ctx.remote.$on('llm/adapters-updated', () => { catalogStore.load(ctx.remote.session); });
934
+ }
935
+ }, 'dsh-cybernetics: model catalog');
936
+ }
937
+ } catch {
938
+ catalogStore = undefined;
939
+ }
940
+ const settingsInject = () => ({ form: settingsForm, directMutate: settingsDirectMutate, catalog: catalogStore });
645
941
  // v0.7.2 (the actual gap): the official Plugins page renders the
646
942
  // 内置插件配置 group cards ONLY from the `plugins.item` slot
647
943
  // (ItemCard in @deepseek-ai/dsh-client-ui-plugin-manager client.js:
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@mars.liu/dsh-cybernetics",
3
- "version": "0.7.8",
3
+ "version": "0.8.4",
4
+ "license": "MIT",
4
5
  "private": false,
5
6
  "files": [
6
7
  "src",
package/src/controller.ts CHANGED
@@ -15,6 +15,31 @@ import type { WorkflowEngine } from '@deepseek-ai/dsh-workflow'
15
15
  /** Directory holding per-round audit logs, relative to the workspace root. */
16
16
  export const STATE_DIR = '.cybernetics'
17
17
 
18
+ /**
19
+ * One call-level model fallback within a round (v0.8): a role's configured
20
+ * model override failed to be called (availability failure) and the role's
21
+ * sub-agent was re-run on the session agent's model (no provider/model opts).
22
+ */
23
+ export interface RoleFallback {
24
+ /** Role whose override was bypassed ('default'/named task role, 'verify', 'triage'). */
25
+ role: string
26
+ /** The configured override that failed. */
27
+ from: { provider?: string; model?: string }
28
+ /**
29
+ * Signal layer (workflow runtime agent() hook, @deepseek-ai/dsh-workflow-ptc):
30
+ * 'availability-error' = the hook THREW a WorkflowError whose code/message
31
+ * matches an availability failure (AGENT_START always; AGENT_RESULT or
32
+ * uncoded errors on quota/rate-limit/auth/5xx/network/provider patterns);
33
+ * 'null-result' = the hook returned null (the runtime flattens a child run
34
+ * that ends stopReason ≠ 'completed' — e.g. a mid-run LLM call failure via
35
+ * dsh-subagent agent/error — into null without the cause), retried only
36
+ * because an override was in effect.
37
+ */
38
+ trigger: 'availability-error' | 'null-result'
39
+ /** Human-readable reason (first line of the runtime error, or the null semantics). */
40
+ reason: string
41
+ }
42
+
18
43
  /** One executed round, as returned by the workflow run. */
19
44
  export interface RoundResult {
20
45
  round: number
@@ -22,6 +47,8 @@ export interface RoundResult {
22
47
  verdict?: unknown
23
48
  triage?: unknown
24
49
  failed?: string
50
+ /** Call-level model fallbacks that occurred in this round (v0.8 audit). */
51
+ fallbacks?: RoleFallback[]
25
52
  /** Execution substrate used for this round (v0.6 audit). */
26
53
  executor?: 'ephemeral' | 'persistent'
27
54
  }
@@ -354,26 +381,42 @@ export async function runRound(
354
381
  }
355
382
  }
356
383
 
384
+ /**
385
+ * v0.8 visibility: render the round's call-level model fallbacks as a decision
386
+ * note suffix, so the tool result names every fallback (role, failed override,
387
+ * trigger) instead of only living in the audit log.
388
+ */
389
+ function fallbackSuffix(result: RoundResult): string {
390
+ const fallbacks = result.fallbacks ?? []
391
+ if (fallbacks.length === 0) return ''
392
+ const items = fallbacks.map((f) => {
393
+ const from = [f.from.provider, f.from.model].filter(Boolean).join('/') || '(inherit)'
394
+ return `${f.role}(${from}→session 模型,${f.trigger === 'null-result' ? '无产出' : '调用失败'})`
395
+ })
396
+ return `;⚠ 模型回退 ${fallbacks.length} 次:${items.join(',')}`
397
+ }
398
+
357
399
  /** Fold one round result into a continue/pause decision. Pure. */
358
400
  function decide(result: RoundResult, state: LoopState, config: ControllerConfig): ControllerDecision {
401
+ const fb = fallbackSuffix(result)
359
402
  if (result.failed) {
360
- return { action: 'pause', reason: 'failed', note: `第 ${result.round} 轮失败:${result.failed}` }
403
+ return { action: 'pause', reason: 'failed', note: `第 ${result.round} 轮失败:${result.failed}${fb}` }
361
404
  }
362
405
  const verdict = result.verdict as { met?: boolean; goalItselfQuestionable?: boolean; gaps?: string[] } | undefined
363
406
  if (verdict && verdict.goalItselfQuestionable) {
364
- return { action: 'pause', reason: 'goal-questionable', note: 'verify 判定目标本身可能有问题,需通过对话重新校准' }
407
+ return { action: 'pause', reason: 'goal-questionable', note: `verify 判定目标本身可能有问题,需通过对话重新校准${fb}` }
365
408
  }
366
409
  if (verdict?.met) {
367
- return { action: 'pause', reason: 'met', note: '验收全部通过;等待用户确认完成' }
410
+ return { action: 'pause', reason: 'met', note: `验收全部通过;等待用户确认完成${fb}` }
368
411
  }
369
412
  const triage = result.triage as { human?: { question?: string; step: string }[]; auto?: { step: string }[] } | undefined
370
413
  const human = triage?.human ?? []
371
414
  if (human.length > 0) {
372
- return { action: 'pause', reason: 'human-input', note: `待用户裁定:${human.map((h) => h.question ?? h.step).join(';')}` }
415
+ return { action: 'pause', reason: 'human-input', note: `待用户裁定:${human.map((h) => h.question ?? h.step).join(';')}${fb}` }
373
416
  }
374
417
  const cap = config.roundsPerSubgoal ?? 3
375
418
  if (state.round >= cap) {
376
- return { action: 'pause', reason: 'round-cap', note: `同一子目标已迭代 ${state.round} 轮仍未达成,升级用户` }
419
+ return { action: 'pause', reason: 'round-cap', note: `同一子目标已迭代 ${state.round} 轮仍未达成,升级用户${fb}` }
377
420
  }
378
421
  const gaps = verdict?.gaps ?? []
379
422
  const auto = triage?.auto ?? []
@@ -382,5 +425,5 @@ function decide(result: RoundResult, state: LoopState, config: ControllerConfig)
382
425
  : auto.length > 0
383
426
  ? auto.map((s) => s.step).join(';')
384
427
  : state.subgoal
385
- return { action: 'continue', subgoal: nextSubgoal, note: `进入第 ${state.round + 1} 轮` }
428
+ return { action: 'continue', subgoal: nextSubgoal, note: `进入第 ${state.round + 1} 轮${fb}` }
386
429
  }