@mars.liu/dsh-cybernetics 0.2.0 → 0.7.8

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,10 +1,19 @@
1
- // dsh-cybernetics client module: the「目标 / Goal」conversation tab.
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
6
  // Rendered from the session's `goal` projection (dsh-goal), pushed live by the
3
7
  // host — no plugin HTTP route needed. Hand-written ModuleLoader wrapper, same
4
8
  // format the host bundler emits (see dsh-context / dsh-maze client.js).
5
9
  /* global window */
10
+ // Registration id = the package name in package.json, NOT the short plugin row
11
+ // id: the client-modules boot graph keys every row by the package manifest name
12
+ // (`@mars.liu/dsh-cybernetics`) and only materializes a row whose bundle
13
+ // registered under exactly that id — a stale `dsh-cybernetics` here executes
14
+ // the batch script fine, then reports "loaded without registering".
6
15
  window.__ModuleLoader__.load({
7
- id: 'dsh-cybernetics',
16
+ id: '@mars.liu/dsh-cybernetics',
8
17
  factory: (require) => {
9
18
  var module = { exports: {} };
10
19
  var exports = module.exports;
@@ -35,6 +44,44 @@ window.__ModuleLoader__.load({
35
44
  'agent.noData': '目标数据未同步',
36
45
  'agents.empty': '本会话当前没有子 agent。',
37
46
  'agents.count': '{running}/{total} 个 agent 运行中',
47
+ // v0.6 multi-goal projection (`cyberneticsGoals`) — every goal of the
48
+ // session in one tab, updated in place.
49
+ 'goals.count': '{count} 个目标',
50
+ 'goal.round': '第 {round} 轮',
51
+ 'goal.subgoal': '当前子目标',
52
+ 'goal.depth.quick': '轻量',
53
+ 'goal.depth.full': '完整',
54
+ 'goal.executor.ephemeral': '临时执行者',
55
+ 'goal.executor.persistent': '持久执行者',
56
+ 'goalStatus.pending': '待启动',
57
+ 'goalStatus.running': '进行中',
58
+ 'goalStatus.paused': '已暂停',
59
+ 'goalStatus.met': '已完成',
60
+ 'goalStatus.cancelled': '已取消',
61
+ 'goalStatus.superseded': '已被取代',
62
+ 'goalStatus.failed': '受阻',
63
+ // v0.6 append-only goal lineage lines (server sets these fields)
64
+ 'goal.supersededBy': '已被「{title}」取代',
65
+ 'goal.revisedFrom': '修订自「{title}」',
66
+ // v0.6 executor line: the durable teammate's name (persistent only)
67
+ 'goal.member': '成员 {name}',
68
+ 'control.goals': '目标',
69
+ // v0.7.2 plugins-page settings card (plugins.item / plugins.row.config /
70
+ // plugins.bundle.config). cardLabel titles the 内置插件配置 group card.
71
+ 'settings.cardLabel': 'Cybernetics 编排循环',
72
+ 'settings.title': '全目标模式',
73
+ 'settings.description': '所有用户请求(含只读问题)必须先经 cybernetics goal。保存后即时生效,无需重载。',
74
+ 'settings.on': '开',
75
+ 'settings.off': '关',
76
+ 'settings.summaryOn': '全目标模式:开',
77
+ 'settings.summaryOff': '全目标模式:关',
78
+ 'settings.unavailable': '配置不可用(设置文档未开放或插件未运行)。',
79
+ 'settings.saving': '保存中…',
80
+ 'settings.saveFailed': '保存未生效(宿主拒绝了写入)。',
81
+ // v0.7.6 path-(b) diagnostics: the direct settings-transport retry
82
+ // surfaces the host's exact RemoteError message (the shared
83
+ // ConfigFormController swallows it into a bare `false`).
84
+ 'settings.saveFailedDetail': '保存未生效,宿主拒绝原因:{message}',
38
85
  };
39
86
  const en = {
40
87
  'view.goal': 'Goal',
@@ -57,6 +104,35 @@ window.__ModuleLoader__.load({
57
104
  'agent.noData': 'Goal data not synced',
58
105
  'agents.empty': 'No child agents in this session yet.',
59
106
  'agents.count': '{running}/{total} agents running',
107
+ 'goals.count': '{count} goals',
108
+ 'goal.round': 'round {round}',
109
+ 'goal.subgoal': 'Current subgoal',
110
+ 'goal.depth.quick': 'Quick',
111
+ 'goal.depth.full': 'Full',
112
+ 'goal.executor.ephemeral': 'Ephemeral executor',
113
+ 'goal.executor.persistent': 'Persistent executor',
114
+ 'goalStatus.pending': 'Pending',
115
+ 'goalStatus.running': 'Running',
116
+ 'goalStatus.paused': 'Paused',
117
+ 'goalStatus.met': 'Complete',
118
+ 'goalStatus.cancelled': 'Cancelled',
119
+ 'goalStatus.superseded': 'Superseded',
120
+ 'goalStatus.failed': 'Blocked',
121
+ 'goal.supersededBy': 'Superseded by "{title}"',
122
+ 'goal.revisedFrom': 'Revised from "{title}"',
123
+ 'goal.member': 'Member {name}',
124
+ 'control.goals': 'Goals',
125
+ 'settings.cardLabel': 'Cybernetics goal loop',
126
+ 'settings.title': 'All-goals mode',
127
+ 'settings.description': 'Every user request (including read-only questions) must go through a cybernetics goal first. Takes effect immediately on save; no reload needed.',
128
+ 'settings.on': 'On',
129
+ 'settings.off': 'Off',
130
+ 'settings.summaryOn': 'All-goals mode: on',
131
+ 'settings.summaryOff': 'All-goals mode: off',
132
+ 'settings.unavailable': 'Configuration unavailable (settings document not served or plugin not running).',
133
+ 'settings.saving': 'Saving…',
134
+ 'settings.saveFailed': 'Save did not take effect (the host refused the write).',
135
+ 'settings.saveFailedDetail': 'Save did not take effect — host refusal: {message}',
60
136
  };
61
137
  //#endregion
62
138
 
@@ -68,12 +144,104 @@ window.__ModuleLoader__.load({
68
144
  };
69
145
 
70
146
  //#region goal view component
71
- // props.goal: ObservableSnapshot face of the session `goal` projection.
72
- // The snapshot may be the raw state ({current: GoalProjection | null, ...})
73
- // or the projection value itself — normalize both; undefined = not yet arrived.
74
- function GoalView(props) {
147
+ // v0.6: the tab is driven by the plugin's own `cyberneticsGoals`
148
+ // projection — the WHOLE multi-goal board of this session, pushed live
149
+ // by the host (one whole-value `cybernetics/goals` event per change, no
150
+ // polling). When that key is absent (older plugin/host) or carries an
151
+ // empty board, fall back to dsh-goal's single-slot `goal` projection,
152
+ // which is exactly the pre-v0.6 rendering.
153
+ const GOAL_STATUS_KEY = {
154
+ pending: 'goalStatus.pending',
155
+ running: 'goalStatus.running',
156
+ paused: 'goalStatus.paused',
157
+ met: 'goalStatus.met',
158
+ cancelled: 'goalStatus.cancelled',
159
+ superseded: 'goalStatus.superseded',
160
+ failed: 'goalStatus.failed',
161
+ };
162
+
163
+ function goalStatusStyle(status) {
164
+ if (status === 'running') return { color: '#3fb950' };
165
+ if (status === 'failed') return { color: '#d29922' };
166
+ if (status === 'met') return { color: '#58a6ff' };
167
+ return { color: '#8b949e' };
168
+ }
169
+
170
+ // End-of-life statuses: the same two-segment rule the goal tab uses
171
+ // (unfinished first, finished after). The server already sorts that way;
172
+ // this stable partition keeps the invariant even if a view arrives with a
173
+ // different order, without re-sorting anything inside a segment.
174
+ const COMPLETED_GOAL_STATUSES = ['met', 'cancelled', 'superseded'];
175
+ function groupGoals(goals) {
176
+ const open = [];
177
+ const done = [];
178
+ for (const g of goals) {
179
+ if (COMPLETED_GOAL_STATUSES.indexOf(g.status) >= 0) done.push(g);
180
+ else open.push(g);
181
+ }
182
+ return open.concat(done);
183
+ }
184
+
185
+ // One goal row: title + status badge + round + current subgoal + tags.
186
+ // Append-only lineage (v0.6): a superseded goal names its successor, a
187
+ // revised goal names where it came from — titles resolved from the board
188
+ // snapshot, falling back to the goalId when the successor is not listed.
189
+ function GoalRow(props) {
75
190
  const t = props.t;
76
- const raw = react.useSyncExternalStore(props.goal.subscribe, props.goal.getSnapshot);
191
+ const g = props.goal;
192
+ const titles = props.titles || {};
193
+ const statusKey = GOAL_STATUS_KEY[g.status] ?? 'goalStatus.pending';
194
+ const depthKey = g.depth === 'quick' ? 'goal.depth.quick' : 'goal.depth.full';
195
+ const executorKey = g.executor === 'persistent' ? 'goal.executor.persistent' : 'goal.executor.ephemeral';
196
+ return jsx.jsxs('div', { style: styles.goalCard, children: [
197
+ jsx.jsxs('div', { style: styles.goalHead, children: [
198
+ jsx.jsx('span', { style: styles.goalTitle, children: g.title || g.goalId }),
199
+ jsx.jsx('span', { style: { ...styles.badge, ...goalStatusStyle(g.status) }, children: t(statusKey) }),
200
+ ] }),
201
+ jsx.jsxs('div', { style: styles.goalMetaRow, children: [
202
+ jsx.jsx('span', { style: styles.meta, children: t('goal.round', { round: g.round ?? 0 }) }),
203
+ jsx.jsx('span', { style: styles.tag, children: t(depthKey) }),
204
+ jsx.jsx('span', { style: styles.tag, children: t(executorKey) }),
205
+ g.memberName
206
+ ? jsx.jsx('span', { style: styles.tag, children: t('goal.member', { name: g.memberName }) })
207
+ : null,
208
+ ] }),
209
+ g.supersededBy
210
+ ? jsx.jsx('div', { style: styles.lineage, children: t('goal.supersededBy', { title: titles[g.supersededBy] || g.supersededBy }) })
211
+ : null,
212
+ g.parentGoalId
213
+ ? jsx.jsx('div', { style: styles.lineage, children: t('goal.revisedFrom', { title: titles[g.parentGoalId] || g.parentGoalId }) })
214
+ : null,
215
+ g.subgoal
216
+ ? jsx.jsxs('div', { style: styles.goalSubgoal, children: [t('goal.subgoal') + ':', g.subgoal] })
217
+ : null,
218
+ ] });
219
+ }
220
+
221
+ // Multi-goal list. Order is the projection's (server-side two segments:
222
+ // unfinished goals first, finished after; newest change first inside each),
223
+ // so the client renders verbatim and never re-sorts.
224
+ function GoalsList(props) {
225
+ const t = props.t;
226
+ const goals = props.goals;
227
+ const titles = {};
228
+ for (const g of goals) titles[g.goalId] = g.title || g.goalId;
229
+ return jsx.jsxs('div', { style: styles.root, children: [
230
+ jsx.jsx('div', { style: styles.badgeRow, children:
231
+ jsx.jsx('span', { style: styles.badge, children: t('goals.count', { count: goals.length }) }) }),
232
+ ...goals.map(function (g) {
233
+ return jsx.jsx(GoalRow, { goal: g, t: t, titles: titles }, g.goalId + ':' + (g.updatedAt ?? 0));
234
+ }),
235
+ jsx.jsx('div', { style: styles.hint, children: t('loopHint') }),
236
+ ] });
237
+ }
238
+
239
+ // props.raw: the session `goal` projection snapshot value. It may be the
240
+ // raw state ({current: GoalProjection | null, ...}) or the projection
241
+ // value itself — normalize both; undefined = not yet arrived.
242
+ function SingleGoalView(props) {
243
+ const t = props.t;
244
+ const raw = props.raw;
77
245
  if (raw === undefined) return null;
78
246
  const goal = raw !== null && typeof raw === 'object' && 'current' in raw ? raw.current : raw;
79
247
  if (goal === null || goal === undefined) {
@@ -93,6 +261,16 @@ window.__ModuleLoader__.load({
93
261
  ] });
94
262
  }
95
263
 
264
+ function GoalView(props) {
265
+ const goalsRaw = react.useSyncExternalStore(props.goals.subscribe, props.goals.getSnapshot);
266
+ const singleRaw = react.useSyncExternalStore(props.goal.subscribe, props.goal.getSnapshot);
267
+ const goals = goalsRaw !== null && typeof goalsRaw === 'object' && Array.isArray(goalsRaw.goals)
268
+ ? goalsRaw.goals
269
+ : [];
270
+ if (goals.length > 0) return jsx.jsx(GoalsList, { t: props.t, goals: goals });
271
+ return jsx.jsx(SingleGoalView, { t: props.t, raw: singleRaw });
272
+ }
273
+
96
274
  var styles = {
97
275
  root: { display: 'flex', flexDirection: 'column', gap: 8, padding: '12px 16px', overflow: 'auto' },
98
276
  empty: { padding: '12px 16px', opacity: 0.6 },
@@ -102,6 +280,17 @@ window.__ModuleLoader__.load({
102
280
  blocked: { color: '#d29922', fontSize: 12 },
103
281
  meta: { opacity: 0.65, fontSize: 12 },
104
282
  hint: { opacity: 0.45, fontSize: 11 },
283
+ // v0.6 multi-goal rows
284
+ goalCard: { display: 'flex', flexDirection: 'column', gap: 4, padding: '8px 10px', border: '1px solid rgba(127,127,127,0.25)', borderRadius: 6 },
285
+ goalHead: { display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 },
286
+ goalTitle: { fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
287
+ goalMetaRow: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' },
288
+ tag: { fontSize: 10, padding: '0 6px', borderRadius: 999, border: '1px solid rgba(127,127,127,0.35)', opacity: 0.75 },
289
+ goalSubgoal: { fontSize: 12, opacity: 0.85, whiteSpace: 'pre-wrap', lineHeight: 1.45 },
290
+ lineage: { fontSize: 11, opacity: 0.6, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
291
+ // v0.6 control tab executor rows
292
+ controlGoalRow: { display: 'flex', alignItems: 'center', gap: 6, minWidth: 0, flexWrap: 'wrap', padding: '2px 0' },
293
+ goalCards: { display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 4 },
105
294
  agentRow: { borderLeft: '2px solid transparent', borderRadius: 4 },
106
295
  agentMain: { display: 'flex', flexDirection: 'column', gap: 2 },
107
296
  agentHead: { display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 },
@@ -122,13 +311,39 @@ window.__ModuleLoader__.load({
122
311
  //#endregion
123
312
 
124
313
  //#region control view component
125
- // The「控制 / Control」tab: every agent of the current session — the lead
126
- // itself plus the subagent catalog (recursively) — with its run state and
127
- // its own durable goal (dsh-goal projection values). All reactive input
128
- // comes from the session list store, which the Session Controller updates
129
- // live via the control stream (running flips, goal/control frames).
314
+ // The「控制 / Control」tab: every goal of the session with its EXECUTOR
315
+ // line (substrate + the durable teammate's name when it runs on one),
316
+ // plus every agent of the current session — the lead itself and the
317
+ // subagent catalog (recursively) — with its run state and its own durable
318
+ // goal (dsh-goal projection values). Goal data comes from the same
319
+ // `cyberneticsGoals` whole-value projection the goal tab reads; the agent
320
+ // tree comes from the session list store, updated live by the Session
321
+ // Controller via the control stream (running flips, goal/control frames).
130
322
  const MAX_TREE_DEPTH = 3;
131
323
 
324
+ // No goal face (older host/plugin): keep the agent tree rendering exactly
325
+ // as before instead of throwing.
326
+ const NOOP_GOALS_FACE = { subscribe: () => () => {}, getSnapshot: () => undefined };
327
+
328
+ // One goal row of the control tab: status + title + executor + durable
329
+ // member name + round. Same fields as the goal tab, restated as the
330
+ // operator's line — who is executing this goal and how far it got.
331
+ function ControlGoalRow(props) {
332
+ const t = props.t;
333
+ const g = props.goal;
334
+ const statusKey = GOAL_STATUS_KEY[g.status] ?? 'goalStatus.pending';
335
+ const executorKey = g.executor === 'persistent' ? 'goal.executor.persistent' : 'goal.executor.ephemeral';
336
+ return jsx.jsxs('div', { style: styles.controlGoalRow, children: [
337
+ jsx.jsx('span', { style: { ...styles.badge, ...goalStatusStyle(g.status) }, children: t(statusKey) }),
338
+ jsx.jsx('span', { style: styles.agentName, children: g.title || g.goalId }),
339
+ jsx.jsx('span', { style: styles.tag, children: t(executorKey) }),
340
+ g.memberName
341
+ ? jsx.jsx('span', { style: styles.tag, children: t('goal.member', { name: g.memberName }) })
342
+ : null,
343
+ jsx.jsx('span', { style: styles.meta, children: t('goal.round', { round: g.round ?? 0 }) }),
344
+ ] });
345
+ }
346
+
132
347
  /** Depth-first agent rows rooted at `rootId`: lead + subagentCatalog, recursed. */
133
348
  function collectAgents(list, rootId) {
134
349
  const rows = [];
@@ -184,9 +399,23 @@ window.__ModuleLoader__.load({
184
399
  const t = props.t;
185
400
  const sessions = props.sessions;
186
401
  const list = react.useSyncExternalStore(sessions.list.subscribe, sessions.list.getSnapshot);
402
+ const goalsFace = props.goals ?? NOOP_GOALS_FACE;
403
+ const goalsRaw = react.useSyncExternalStore(goalsFace.subscribe, goalsFace.getSnapshot);
404
+ const goals = goalsRaw !== null && typeof goalsRaw === 'object' && Array.isArray(goalsRaw.goals)
405
+ ? groupGoals(goalsRaw.goals)
406
+ : [];
187
407
  const agents = collectAgents(list, props.sessionId);
188
408
  const runningCount = agents.filter((a) => a.running).length;
189
409
  return jsx.jsxs('div', { style: styles.root, children: [
410
+ goals.length > 0
411
+ ? jsx.jsxs('div', { style: styles.goalCards, children: [
412
+ jsx.jsxs('div', { style: styles.badgeRow, children: [
413
+ jsx.jsx('span', { style: styles.meta, children: t('control.goals') }),
414
+ jsx.jsx('span', { style: styles.badge, children: t('goals.count', { count: goals.length }) }),
415
+ ] }),
416
+ ...goals.map((g) => jsx.jsx(ControlGoalRow, { goal: g, t }, 'goal:' + g.goalId + ':' + (g.updatedAt ?? 0))),
417
+ ] })
418
+ : null,
190
419
  jsx.jsxs('div', { style: styles.badgeRow, children: [
191
420
  jsx.jsx('span', { style: styles.badge, children: t('agents.count', { total: agents.length, running: runningCount }) }),
192
421
  ] }),
@@ -196,14 +425,267 @@ window.__ModuleLoader__.load({
196
425
  }
197
426
  //#endregion
198
427
 
428
+ //#region plugins-page settings card (v0.7.2)
429
+ // The official Plugins page (@deepseek-ai/dsh-client-ui-plugin-manager, the
430
+ // runtime copy that owns the 内置插件配置/实验性插件/已安装插件 groups) renders a
431
+ // bundle's own configuration ONLY through the slots it declares:
432
+ // plugins.bundle.config keyed by the bundle name (profile dep name)
433
+ // plugins.row.config keyed by `<bundle name>#<row id>`
434
+ // (rowConfigKey in that page's client.js; slot contract in
435
+ // dsh-cordis-client-runner's slot catalog). A server-side Config schema
436
+ // alone surfaces nowhere once the ui-settings-plugins section is disabled
437
+ // by the family plugin-manager — exactly the observed gap.
438
+ // Both entries bind the row's own ConfigForm from ctx.configForms (the
439
+ // namespace id is the loader row id `dsh-cybernetics`, same convention
440
+ // @deepseek-ai/dsh-experimental-client-ui-cot-translation uses). A toggle
441
+ // write goes settings mutate → loader volatile commit
442
+ // (`loader/volatile-update`), so allGoalsMode stays live without a
443
+ // restart — the v0.7.1 volatile semantics, unchanged.
444
+ const SETTINGS_ENTRY_ID = 'dsh-cybernetics';
445
+ const NO_FORM_SNAP = { status: 'unavailable', value: undefined, writable: false };
446
+ const noopSubscribe = () => () => {};
447
+
448
+ var settingsStyles = {
449
+ card: { display: 'flex', flexDirection: 'column', gap: 6, padding: '12px 0' },
450
+ row: { display: 'flex', alignItems: 'center', gap: 12 },
451
+ main: { display: 'flex', flexDirection: 'column', gap: 2, flex: 1, minWidth: 0 },
452
+ title: { fontSize: 13, fontWeight: 600 },
453
+ desc: { fontSize: 12, opacity: 0.65, lineHeight: 1.5 },
454
+ toggle: {
455
+ cursor: 'pointer', borderRadius: 999, width: 40, height: 22,
456
+ border: '1px solid rgba(127,127,127,0.45)', position: 'relative', flex: 'none',
457
+ background: 'transparent', padding: 0,
458
+ },
459
+ knob: {
460
+ position: 'absolute', top: 2, width: 16, height: 16, borderRadius: 999,
461
+ transition: 'left .15s ease',
462
+ },
463
+ trackOn: { background: '#2da44e', borderColor: '#2da44e' },
464
+ trackOff: { background: 'rgba(127,127,127,0.25)' },
465
+ knobOn: { left: 20, background: '#fff' },
466
+ knobOff: { left: 2, background: 'currentColor' },
467
+ unavailable: { fontSize: 12, opacity: 0.6 },
468
+ };
469
+
470
+ /**
471
+ * One boolean settings row; shared by all three seats. Two form shapes
472
+ * reach it, and the card must speak both:
473
+ * - 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;
477
+ * - the HOST's ConfigPageForm { state, mutate }: the plugins.item and
478
+ * plugins.row.config detail pages compute it from the same shared
479
+ * mirror (configForms.get(id).getSnapshot()/mutate) and pass it as
480
+ * an OWNER prop, which spreads AFTER inject at the render seam
481
+ * (@deepseek-ai/dsh-client-ui-renderer renderEntry: ...injected,
482
+ * ...ownerProps) — on those pages this shape WINS over the injected
483
+ * form. Reading `.subscribe` on it crashed the first v0.7.2 cut.
484
+ * The host shape carries no subscribe; its `state` is re-computed by
485
+ * the owning page on every mirror update, so reading it per render is
486
+ * already live.
487
+ */
488
+ function SettingsCard(props) {
489
+ const t = props.t;
490
+ const form = props.form;
491
+ // v0.7.6 path-(b) fallback write face: the plugin's own bound
492
+ // remote.settings.mutate. SAME host write path as the ConfigForm
493
+ // (remote.settings.mutate → @deepseek-ai/dsh-settings SettingsForms
494
+ // → @deepseek-ai/dsh-config-editor edit → the USER patch row
495
+ // `{id, name, config}` appended to the profile cordis.patch.yml,
496
+ // reconciled by reconcileProfilePatches) — the only落点 the host
497
+ // accepts. Used ONLY when the primary write refuses, without an
498
+ // expectedRevision (the controller documents undefined as an
499
+ // unconditional write), so a stale-revision refusal heals in place
500
+ // and a hard refusal surfaces its exact host message instead of a
501
+ // bare「宿主拒绝了写入」with no reason.
502
+ const directMutate = props.directMutate;
503
+ const view = props.view === 'summary' ? 'summary' : 'page';
504
+ const live = form !== null && typeof form === 'object'
505
+ && typeof form.subscribe === 'function' && typeof form.getSnapshot === 'function' && typeof form.set === 'function'
506
+ ? form : undefined;
507
+ const host = live === undefined && form !== null && typeof form === 'object'
508
+ && typeof form.mutate === 'function' && form.state !== null && typeof form.state === 'object'
509
+ ? form : undefined;
510
+ const liveSnap = react.useSyncExternalStore(
511
+ live ? (cb) => live.subscribe(cb) : noopSubscribe,
512
+ live ? () => live.getSnapshot() : () => NO_FORM_SNAP,
513
+ );
514
+ const snap = host !== undefined ? host.state : liveSnap;
515
+ const [saving, setSaving] = react.useState(false);
516
+ // `false` = no failure; `true` = failure without a host message;
517
+ // a string = the host's exact refusal (RemoteError.message).
518
+ const [failed, setFailed] = react.useState(false);
519
+ const on = snap.status === 'ready' && snap.value != null && snap.value.allGoalsMode === true;
520
+ const disabled = (live === undefined && host === undefined) || snap.status !== 'ready' || snap.writable === false || saving;
521
+
522
+ const toggle = () => {
523
+ if (disabled) return;
524
+ setSaving(true);
525
+ 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.
543
+ let settled = false;
544
+ const finish = (ok, detail) => {
545
+ if (settled) return;
546
+ settled = true;
547
+ setSaving(false);
548
+ if (!ok) {
549
+ setFailed(detail || true);
550
+ setTimeout(() => setFailed(false), 6000);
551
+ }
552
+ };
553
+ const guard = setTimeout(() => finish(false), 15000);
554
+ const op = { op: 'set', path: ['allGoalsMode'], value: !on };
555
+ const write = live !== undefined
556
+ ? live.set('allGoalsMode', !on)
557
+ : host.mutate([op], snap.revision);
558
+ Promise.resolve(write)
559
+ .then(async (accepted) => {
560
+ if (accepted !== false) { clearTimeout(guard); finish(true); return; }
561
+ if (typeof directMutate !== 'function') { clearTimeout(guard); finish(false); return; }
562
+ try {
563
+ const answer = await directMutate([op]);
564
+ clearTimeout(guard);
565
+ if (answer && typeof answer === 'object' && answer.ok === false) {
566
+ finish(false, answer.error && answer.error.message);
567
+ } else finish(true);
568
+ } catch (error) {
569
+ clearTimeout(guard);
570
+ finish(false, error && error.message);
571
+ }
572
+ })
573
+ .catch((error) => { clearTimeout(guard); finish(false, error && error.message); });
574
+ };
575
+
576
+ if (view === 'summary') {
577
+ return jsx.jsx('span', { children: snap.status === 'ready' ? t(on ? 'settings.summaryOn' : 'settings.summaryOff') : t('settings.unavailable') });
578
+ }
579
+
580
+ return jsx.jsxs('div', { style: settingsStyles.card, children: [
581
+ jsx.jsxs('div', { style: settingsStyles.row, children: [
582
+ jsx.jsxs('div', { style: settingsStyles.main, children: [
583
+ jsx.jsx('div', { style: settingsStyles.title, children: t('settings.title') }),
584
+ jsx.jsx('div', { style: settingsStyles.desc, children: t('settings.description') }),
585
+ ] }),
586
+ snap.status === 'ready'
587
+ ? jsx.jsx('button', {
588
+ type: 'button', role: 'switch', 'aria-checked': on, 'aria-label': t('settings.title'),
589
+ disabled, onClick: toggle,
590
+ style: { ...settingsStyles.toggle, ...(on ? settingsStyles.trackOn : settingsStyles.trackOff) },
591
+ children: jsx.jsx('span', { style: { ...settingsStyles.knob, ...(on ? settingsStyles.knobOn : settingsStyles.knobOff) } }),
592
+ })
593
+ : null,
594
+ ] }),
595
+ snap.status !== 'ready' ? jsx.jsx('div', { style: settingsStyles.unavailable, children: t('settings.unavailable') }) : null,
596
+ saving ? jsx.jsx('div', { style: settingsStyles.unavailable, children: t('settings.saving') }) : null,
597
+ !saving && failed ? jsx.jsx('div', { style: { ...settingsStyles.unavailable, color: '#d29922' }, children: failed === true ? t('settings.saveFailed') : t('settings.saveFailedDetail', { message: failed }) }) : null,
598
+ ] });
599
+ }
600
+ //#endregion
601
+
199
602
  //#region plugin apply
200
603
  const name = 'dsh-cybernetics';
201
- const inject = ['slots', 'sessions', 'locale'];
604
+ // configForms: the per-entry ConfigForm registry (@deepseek-ai/dsh-client-ui-settings)
605
+ // that backs the plugins-page settings card below.
606
+ const inject = ['slots', 'sessions', 'locale', 'configForms',
607
+ // v0.7.6: the settings card's diagnostic fallback write rides the
608
+ // same namespace remote the official ui-settings declares
609
+ // (["remote", "remote.settings"]). Both are required: ctx access is
610
+ // gated by the inject declaration (client-runner guard), so an
611
+ // undeclared `remote` would throw on read and silently disable the
612
+ // fallback. Optional at runtime (guarded in apply): a host without
613
+ // the namespace parks this client module exactly as it would
614
+ // ui-settings itself — the web profile always serves it.
615
+ 'remote', 'remote.settings'];
202
616
 
203
617
  function apply(ctx) {
204
618
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-cybernetics: dictionaries');
205
619
  const t = ctx.locale.bind(NS);
206
620
 
621
+ // v0.7.2: bind the row's ConfigForm once per apply body. The namespace
622
+ // is the loader row id (`dsh-cybernetics`), the same convention the
623
+ // official cot-translation bundle uses (`ctx.configForms.get(ns)`). If
624
+ // the host does not serve this namespace yet, the card renders its
625
+ // unavailable state instead of throwing.
626
+ let settingsForm;
627
+ try {
628
+ settingsForm = ctx.configForms.get(SETTINGS_ENTRY_ID);
629
+ } catch {
630
+ settingsForm = undefined;
631
+ }
632
+ // v0.7.6 path-(b) diagnostics face: the direct settings transport,
633
+ // bound from this module's own context (`remote.settings` inject).
634
+ // Guarded: a deployment without the namespace leaves it undefined
635
+ // and the card falls back to the v0.7.4 hint-only failure state.
636
+ let settingsDirectMutate;
637
+ try {
638
+ if (ctx.remote && ctx.remote.settings && typeof ctx.remote.settings.mutate === 'function') {
639
+ settingsDirectMutate = (ops, expectedRevision) => ctx.remote.settings.mutate(SETTINGS_ENTRY_ID, ops, expectedRevision);
640
+ }
641
+ } catch {
642
+ settingsDirectMutate = undefined;
643
+ }
644
+ const settingsInject = () => ({ form: settingsForm, directMutate: settingsDirectMutate });
645
+ // v0.7.2 (the actual gap): the official Plugins page renders the
646
+ // 内置插件配置 group cards ONLY from the `plugins.item` slot
647
+ // (ItemCard in @deepseek-ai/dsh-client-ui-plugin-manager client.js:
648
+ // basicCards = ledger.items from slot "plugins.item"; ItemDetail
649
+ // renders view "page" with the host form for item.id). The keyed
650
+ // seats below surface only on an installed bundle's own page, which
651
+ // this linked bundle never reaches in the 已安装插件 group — that was
652
+ // why v0.7.1's schema alone (and the first v0.7.2 cut) showed no card.
653
+ // Register the list card UNCONDITIONALLY (v0.7.5 hardening): the earlier
654
+ // cut gated it on configForms.whileServed, so a deployment where the
655
+ // host does not (yet) serve the `dsh-cybernetics` namespace showed NO
656
+ // card at all — indistinguishable from the registration never running.
657
+ // ctx.configForms.get() binds a live ConfigFormController that follows
658
+ // the shared describe mirror (ConfigForms.get → new ConfigFormController
659
+ // + mirror.ensure), so a card registered before the namespace appears
660
+ // flips to the toggle the moment the host starts serving it; while it
661
+ // does not, the card renders its `settings.unavailable` line — visible
662
+ // and diagnosable instead of absent.
663
+ ctx.effect(() => ctx.slots.inject('plugins.item', () => ctx.slots.register({
664
+ name: 'plugins.item',
665
+ id: SETTINGS_ENTRY_ID,
666
+ order: 80,
667
+ label: () => t('settings.cardLabel'),
668
+ locale: NS,
669
+ inject: settingsInject,
670
+ }, SettingsCard)), 'dsh-cybernetics: plugins-page settings card');
671
+ // The dispatch key bundles use the profile dependency name (the value
672
+ // in dsh.profile.bundles = `dsh-cybernetics`); register the scoped
673
+ // package manifest name too, so either resolution finds the card.
674
+ for (const bundleKey of ['dsh-cybernetics', '@mars.liu/dsh-cybernetics']) {
675
+ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
676
+ name: 'plugins.row.config',
677
+ key: bundleKey + '#' + SETTINGS_ENTRY_ID,
678
+ locale: NS,
679
+ inject: settingsInject,
680
+ }, SettingsCard));
681
+ ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
682
+ name: 'plugins.bundle.config',
683
+ key: bundleKey,
684
+ locale: NS,
685
+ inject: settingsInject,
686
+ }, SettingsCard));
687
+ }
688
+
207
689
  // Conversation-view tab, same slot the trajectory/context tabs use.
208
690
  // `t` reaches the view as a slot-provided prop from `locale: NS`
209
691
  // (PropsLocale) — do NOT also pass a hand-bound t through inject:
@@ -218,19 +700,39 @@ window.__ModuleLoader__.load({
218
700
  inject: (sessionId) => {
219
701
  const binding = ctx.sessions.binding(sessionId);
220
702
  if (binding === undefined) throw new Error('dsh-cybernetics: session "' + sessionId + '" unavailable');
221
- return { goal: binding.session.projections.faceOf('goal') };
703
+ // v0.6: both faces. `cyberneticsGoals` carries the whole goal
704
+ // board (live, pushed by the host); `goal` stays as the
705
+ // backward-compatible fallback for older plugin/host builds.
706
+ return {
707
+ goals: binding.session.projections.faceOf('cyberneticsGoals'),
708
+ goal: binding.session.projections.faceOf('goal'),
709
+ };
222
710
  },
223
711
  }, GoalView));
224
712
 
225
713
  // Control tab: goals + run states of every agent in this session,
226
714
  // driven by the session list store (live via the control stream).
715
+ // The goal face is optional (older host/plugin): the agent tree then
716
+ // renders exactly as before.
227
717
  ctx.slots.inject('conversation.view', () => ctx.slots.register({
228
718
  name: 'conversation.view',
229
719
  id: 'cybernetics-control',
230
720
  order: 16,
231
721
  locale: NS,
232
722
  label: () => t('view.control'),
233
- inject: (sessionId) => ({ sessionId, sessions: ctx.sessions }),
723
+ inject: (sessionId) => {
724
+ let goals = NOOP_GOALS_FACE;
725
+ try {
726
+ const binding = ctx.sessions.binding(sessionId);
727
+ const face = binding && binding.session && binding.session.projections
728
+ ? binding.session.projections.faceOf('cyberneticsGoals')
729
+ : undefined;
730
+ if (face) goals = face;
731
+ } catch {
732
+ // No multi-goal projection: keep the control tab's agent tree.
733
+ }
734
+ return { sessionId, sessions: ctx.sessions, goals };
735
+ },
234
736
  }, ControlView));
235
737
  }
236
738
  //#endregion
package/package.json CHANGED
@@ -1,7 +1,16 @@
1
1
  {
2
2
  "name": "@mars.liu/dsh-cybernetics",
3
- "version": "0.2.0",
3
+ "version": "0.7.8",
4
4
  "private": false,
5
+ "files": [
6
+ "src",
7
+ "lib",
8
+ "workflows",
9
+ "docs",
10
+ "locale",
11
+ "cordis.patch.yml",
12
+ "README.md"
13
+ ],
5
14
  "description": "Cybernetic work protocol for DSH: the session goal (dsh-goal) driven loop of task -> verify -> triage agents, implicitly advanced via one workflow run per round. Server plugin + client goal tab.",
6
15
  "type": "module",
7
16
  "main": "src/index.ts",
@@ -24,10 +33,17 @@
24
33
  "@deepseek-ai/dsh-client-locale",
25
34
  "@deepseek-ai/dsh-client-ui-conversation",
26
35
  "@deepseek-ai/dsh-client-ui-renderer",
27
- "@deepseek-ai/dsh-client-ui-session"
36
+ "@deepseek-ai/dsh-client-ui-session",
37
+ "@deepseek-ai/dsh-client-ui-settings"
28
38
  ],
29
39
  "platform": "web",
30
40
  "immediately": true
31
41
  }
42
+ },
43
+ "dependencies": {
44
+ "@deepseek-ai/schemastery": "^3.18.5"
45
+ },
46
+ "peerDependencies": {
47
+ "@deepseek-ai/dsh-session": "*"
32
48
  }
33
49
  }