@zfdx123/dsh-hooks-ordering 1.0.1

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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +257 -0
  3. package/client.js +619 -0
  4. package/cordis.patch.yml +38 -0
  5. package/lib/dag-Bqx-sl71.d.ts +55 -0
  6. package/lib/dag-Bqx-sl71.d.ts.map +1 -0
  7. package/lib/dag-DVhoBjBG.js +48 -0
  8. package/lib/dag-DVhoBjBG.js.map +1 -0
  9. package/lib/dag.d.ts +3 -0
  10. package/lib/dag.js +3 -0
  11. package/lib/index.d.ts +121 -0
  12. package/lib/index.d.ts.map +1 -0
  13. package/lib/index.js +190 -0
  14. package/lib/index.js.map +1 -0
  15. package/lib/serial-Cu7usHjI.js +65 -0
  16. package/lib/serial-Cu7usHjI.js.map +1 -0
  17. package/lib/serial-D8ZJCBKL.d.ts +55 -0
  18. package/lib/serial-D8ZJCBKL.d.ts.map +1 -0
  19. package/lib/serial.d.ts +5 -0
  20. package/lib/serial.js +6 -0
  21. package/lib/service-base-CCmIBwnB.d.ts +100 -0
  22. package/lib/service-base-CCmIBwnB.d.ts.map +1 -0
  23. package/lib/service-base-a5vKg62S.js +139 -0
  24. package/lib/service-base-a5vKg62S.js.map +1 -0
  25. package/lib/service-base.d.ts +4 -0
  26. package/lib/service-base.js +5 -0
  27. package/lib/topo-sort-BZ1fFcTs.d.ts +54 -0
  28. package/lib/topo-sort-BZ1fFcTs.d.ts.map +1 -0
  29. package/lib/topo-sort-CfwYPY4U.js +83 -0
  30. package/lib/topo-sort-CfwYPY4U.js.map +1 -0
  31. package/lib/topo-sort.d.ts +2 -0
  32. package/lib/topo-sort.js +3 -0
  33. package/lib/waterfall-Bu6m9gYc.js +83 -0
  34. package/lib/waterfall-Bu6m9gYc.js.map +1 -0
  35. package/lib/waterfall-_5HkptkS.d.ts +87 -0
  36. package/lib/waterfall-_5HkptkS.d.ts.map +1 -0
  37. package/lib/waterfall.d.ts +5 -0
  38. package/lib/waterfall.js +6 -0
  39. package/package.json +117 -0
  40. package/src/dag.ts +92 -0
  41. package/src/dsh.ts +181 -0
  42. package/src/index.ts +54 -0
  43. package/src/serial.ts +108 -0
  44. package/src/service-base.ts +179 -0
  45. package/src/settings.ts +97 -0
  46. package/src/topo-sort.ts +118 -0
  47. package/src/waterfall.ts +153 -0
package/client.js ADDED
@@ -0,0 +1,619 @@
1
+ // dsh-hooks-ordering — client half (classic script, no build step).
2
+ //
3
+ // dsh renders a settings UI from the CLIENT plane: a host-side
4
+ // `settings.register(ns, schema)` only creates the namespace, its storage and
5
+ // its descriptor. The form itself is a slot contribution, so this file
6
+ // registers one settings page for the `hooks-ordering` namespace.
7
+ //
8
+ // Two conventions are load-bearing and both come from how the harness loads
9
+ // this file:
10
+ //
11
+ // - `id` must be the PACKAGE NAME. The host fetches /plugins/<package>/client.js
12
+ // from the __DSH_BOOT__ graph and then asserts that the loaded bundle
13
+ // registered that id. A short name throws during mount and nothing renders.
14
+ // - the file is a classic script, so it has no import/export: React arrives
15
+ // through the synchronous `require` handed to the factory.
16
+ //
17
+ // Writes go through `scope.mutate` rather than `scope.set`: the latter is
18
+ // documented for a "scalar field", while our fields are string arrays, and
19
+ // path-addressed ops say exactly what is meant for both save and reset.
20
+ //
21
+ // The namespace is `applies: 'restart'`, so the page says so instead of
22
+ // pretending a save took effect immediately.
23
+ //
24
+ // All user-visible copy lives in the plugin's own zh/en dictionaries
25
+ // (namespace `hooks-ordering`, matching the settings namespace) and the nav
26
+ // label is a thunk through the bound translator, so the settings navigation
27
+ // follows the shell's language. `ctx.locale` is injected; a context without it
28
+ // falls back to the Chinese copy rather than throwing.
29
+ //
30
+ // The IIFE keeps the pure helpers in one private scope: a classic script's
31
+ // top-level declarations would become page globals, and leaving them inside the
32
+ // factory would recreate them per call.
33
+ ;(function () {
34
+ /** @param value - a settings field; @returns its textarea form, one entry per line. */
35
+ function toLines(value) {
36
+ return Array.isArray(value) ? value.join('\n') : ''
37
+ }
38
+
39
+ /** @param text - textarea content; @returns the trimmed, blank-free entries. */
40
+ function fromLines(text) {
41
+ return String(text == null ? '' : text)
42
+ .split('\n')
43
+ .map(function (line) {
44
+ return line.trim()
45
+ })
46
+ .filter(function (line) {
47
+ return line.length > 0
48
+ })
49
+ }
50
+
51
+ /** @param value - current value; @param text - draft text; @returns whether they describe the same list. */
52
+ function sameLines(value, text) {
53
+ return toLines(value) === fromLines(text).join('\n')
54
+ }
55
+
56
+ /**
57
+ * @param template - a dictionary entry, `{name}` placeholders.
58
+ * @param params - placeholder values, or absent for the raw template.
59
+ * @returns the template with every supplied placeholder replaced, matching
60
+ * the shell LocaleRuntime.translate rules (an unsupplied placeholder stays).
61
+ */
62
+ function interpolate(template, params) {
63
+ if (!params) return template
64
+ return String(template).replace(/\{(\w+)\}/g, function (match, name) {
65
+ return Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match
66
+ })
67
+ }
68
+
69
+ // eslint-disable-next-line no-underscore-dangle -- the loader's own global; the name is not ours to choose.
70
+ window.__ModuleLoader__.load({
71
+ id: '@zfdx123/dsh-hooks-ordering',
72
+ factory: function (require) {
73
+ var module = { exports: {} }
74
+ var exports = module.exports
75
+ var React = require('react')
76
+ var e = React.createElement
77
+ var useState = React.useState
78
+ var useEffect = React.useEffect
79
+ var useRef = React.useRef
80
+
81
+ var NS = 'hooks-ordering'
82
+ var CLASS = 'dsh-hooks-ordering'
83
+ var STYLE_ID = 'dsh-hooks-ordering-style'
84
+
85
+ // ── locale ─────────────────────────────────────────────────────────────
86
+ // Every user-visible string in this half lives in the two dictionaries
87
+ // below, registered under the plugin's own namespace (the same one the
88
+ // settings scope uses). The nav label is a thunk: the shell never
89
+ // subscribes locale state, it re-renders the nav on a locale switch and
90
+ // calls label() again, so the thunk always answers in the active
91
+ // language.
92
+ //
93
+ // `ctx.locale` is a required service, but this file still degrades: when
94
+ // the service is absent (a composition without the locale plugin, or the
95
+ // stub context the Node tests hand in) the translator falls back to the
96
+ // plugin's own Chinese copy and the page registers and renders as before.
97
+ var zh = {
98
+ nav: '钩子排序',
99
+ intro: '协调器接管这些钩子,让参与者用 before/after 声明顺序,而不是听天由命于插件加载顺序。',
100
+ loading: '正在读取设置…',
101
+ unavailable: '命名空间 hooks-ordering 未暴露给本客户端:请确认插件已加载,且当前是 web profile。',
102
+ fieldHooks: 'waterfall 钩子(每行一个)',
103
+ fieldSerialHooks: 'serial 钩子(每行一个)',
104
+ fieldLog: '约束 DAG 日志文件(留空即不记录)',
105
+ note: '改动在重启 dsh 后生效(该命名空间 applies: restart)。清空某字段即回到组合层:目前 base = {hooks} 个 waterfall / {serial} 个 serial 钩子。',
106
+ saveFailed: '保存失败:{message}',
107
+ saving: '保存中…',
108
+ save: '保存',
109
+ reset: '恢复组合默认',
110
+ readonly: '当前连接的设置存储是只读的。',
111
+ }
112
+ var en = {
113
+ nav: 'Hook ordering',
114
+ intro:
115
+ 'The coordinator takes over these hooks so participants declare their order with before/after instead of leaving it to plugin load order.',
116
+ loading: 'Reading settings…',
117
+ unavailable:
118
+ 'The hooks-ordering namespace is not exposed to this client: check that the plugin is loaded and that this is the web profile.',
119
+ fieldHooks: 'Waterfall hooks (one per line)',
120
+ fieldSerialHooks: 'Serial hooks (one per line)',
121
+ fieldLog: 'Constraint DAG log file (leave empty to log nothing)',
122
+ note: 'Changes take effect after dsh restarts (the namespace is applies: restart). Clearing a field returns it to the composition layer: base = {hooks} waterfall / {serial} serial hooks right now.',
123
+ saveFailed: 'Save failed: {message}',
124
+ saving: 'Saving…',
125
+ save: 'Save',
126
+ reset: 'Restore composition defaults',
127
+ readonly: 'The connected settings store is read-only.',
128
+ }
129
+
130
+ /** Fallback translator: the plugin's own Chinese copy (a missing key shows the key, as the shell does). */
131
+ function translateZh(key, params) {
132
+ return interpolate(Object.prototype.hasOwnProperty.call(zh, key) ? zh[key] : key, params)
133
+ }
134
+
135
+ // The active translator: Chinese by default, replaced by the locale
136
+ // service's bound t when it is available. Components read it at render
137
+ // time, so a locale switch shows through on the next render.
138
+ var t = translateZh
139
+
140
+ /** Register both dictionaries and bind the translator; stays on Chinese if the service is missing or refuses. */
141
+ function bindLocale(ctx) {
142
+ var locale = ctx && ctx.locale
143
+ if (!locale || typeof locale.register !== 'function' || typeof locale.bind !== 'function') {
144
+ // No locale service: answer in the plugin's own Chinese copy (this
145
+ // apply's context decides, even if an earlier one had bound a
146
+ // translator).
147
+ t = translateZh
148
+ return
149
+ }
150
+ try {
151
+ ctx.effect(function () {
152
+ return locale.register(NS, { zh: zh, en: en })
153
+ }, 'hooks-ordering: dictionaries')
154
+ t = locale.bind(NS)
155
+ } catch {
156
+ // e.g. the namespace is already occupied by another instance. That
157
+ // must not take the whole settings page down.
158
+ t = translateZh
159
+ }
160
+ }
161
+
162
+ // ── shell primitives ───────────────────────────────────────────────────
163
+ // The shell ships its UI kit in the module-table baseline
164
+ // (@deepseek-ai/dsh-client-ui-primitives), so this plugin asks for it
165
+ // directly instead of hand-rolling buttons and chips: the shell's own
166
+ // settings pages, confirmations and toasts are built from the same atoms,
167
+ // which is what lines up focus rings, disabled states, transitions and
168
+ // accessibility semantics with the shell.
169
+ //
170
+ // The require is guarded. A module-table miss throws, and a throw inside
171
+ // the factory would take the whole plugin down, so the shape is checked and
172
+ // every failure returns null. Each primitive used below then has a
173
+ // plain-element fallback with the same props contract, which renders the
174
+ // pre-kit appearance instead of a blank page.
175
+ var PRIMITIVES = '@deepseek-ai/dsh-client-ui-primitives'
176
+ // Why the kit could not be taken ('' when it was): kept for diagnostics and
177
+ // so a Node test can prove the guard caught a real error rather than a
178
+ // missing branch.
179
+ var primitivesError = ''
180
+ var UI = loadPrimitives()
181
+
182
+ function loadPrimitives() {
183
+ try {
184
+ var primitives = require(PRIMITIVES)
185
+ if (
186
+ primitives &&
187
+ typeof primitives.Button === 'function' &&
188
+ typeof primitives.Input === 'function' &&
189
+ typeof primitives.Tag === 'function' &&
190
+ typeof primitives.StateDot === 'function'
191
+ ) {
192
+ return primitives
193
+ }
194
+ return null
195
+ } catch (error) {
196
+ // No such entry in the module table (or the require was refused): record
197
+ // the reason, then render the plugin's own elements instead.
198
+ primitivesError = String((error && error.message) || error)
199
+ return null
200
+ }
201
+ }
202
+
203
+ // ── controls: the kit when it is there, the plugin's own elements when not ─
204
+
205
+ /** Button element. `variant` is the shell's primary/outline family. */
206
+ function button(props, children) {
207
+ if (UI !== null) {
208
+ return e(
209
+ UI.Button,
210
+ {
211
+ key: props.key,
212
+ variant: props.variant || 'outline',
213
+ size: 'sm',
214
+ disabled: props.disabled,
215
+ title: props.title,
216
+ onClick: props.onClick,
217
+ },
218
+ children,
219
+ )
220
+ }
221
+ return e(
222
+ 'button',
223
+ {
224
+ key: props.key,
225
+ type: 'button',
226
+ className: props.variant === 'primary' ? 'ho-fallback-btn ho-primary' : 'ho-fallback-btn',
227
+ disabled: props.disabled,
228
+ title: props.title,
229
+ onClick: props.onClick,
230
+ },
231
+ children,
232
+ )
233
+ }
234
+
235
+ /**
236
+ * Single-line field element. The fallback deliberately carries no class: the
237
+ * stylesheet's `input:not([class])` rule colours the plugin's own fields,
238
+ * while the shell Input's own <input> carries the shell's class.
239
+ */
240
+ function textInput(props) {
241
+ if (UI !== null) {
242
+ return e(UI.Input, {
243
+ className: 'ho-input',
244
+ id: props.id,
245
+ type: 'text',
246
+ value: props.value,
247
+ disabled: props.disabled,
248
+ onChange: props.onChange,
249
+ })
250
+ }
251
+ return e('input', {
252
+ id: props.id,
253
+ type: 'text',
254
+ value: props.value,
255
+ disabled: props.disabled,
256
+ onChange: props.onChange,
257
+ })
258
+ }
259
+
260
+ /** Tag element (read-only chip). Keeps its own class on both paths. */
261
+ function tag(tone, children, className) {
262
+ if (UI !== null) return e(UI.Tag, { tone: tone, className: className }, children)
263
+ return e('span', { className: className ? 'ho-tag ' + className : 'ho-tag' }, children)
264
+ }
265
+
266
+ /** State dot element; the text it belongs to is always rendered beside it. */
267
+ function dot(state) {
268
+ if (UI !== null) return e(UI.StateDot, { state: state, size: 10 })
269
+ return e('span', { className: 'ho-dot ho-dot-' + state })
270
+ }
271
+
272
+ // ── theme ──────────────────────────────────────────────────────────────
273
+ // The shell defines its design tokens on <body> (light) and
274
+ // <body[data-ds-dark-theme]> (dark), so var(--dsw-alias-*) resolves here.
275
+ // The shell never declares color-scheme, so native controls (textarea
276
+ // internals, scrollbars, select popups) would stay light in dark mode:
277
+ // this block pins color-scheme per theme and gives every token a fallback.
278
+ var CSS = [
279
+ '.' + CLASS + '{',
280
+ 'color-scheme:light;',
281
+ '--ho-fg:var(--dsw-alias-label-primary,#0f1115);',
282
+ '--ho-fg-2:var(--dsw-alias-label-secondary,#61666b);',
283
+ '--ho-border:var(--dsw-alias-border-l2,rgba(0,0,0,.1));',
284
+ '--ho-field:var(--dsw-alias-bg-layer-2,#f9fafb);',
285
+ '--ho-accent:var(--dsw-alias-button-primary-fill,#0f1115);',
286
+ '--ho-accent-fg:var(--dsw-alias-label-primary-foreground,#fff);',
287
+ 'color:var(--ho-fg);',
288
+ '}',
289
+ 'body[data-ds-dark-theme] .' + CLASS + '{',
290
+ 'color-scheme:dark;',
291
+ '--ho-fg:var(--dsw-alias-label-primary,#ebeef2);',
292
+ '--ho-fg-2:var(--dsw-alias-label-secondary,#adb2b8);',
293
+ '--ho-border:var(--dsw-alias-border-l2,rgba(255,255,255,.14));',
294
+ '--ho-field:var(--dsw-alias-bg-layer-3,#232326);',
295
+ '--ho-accent:var(--dsw-alias-button-primary-fill,#ebeef2);',
296
+ '--ho-accent-fg:var(--dsw-alias-label-primary-foreground,#151517);',
297
+ '}',
298
+ '.' + CLASS + '{display:flex;flex-direction:column;gap:18px;padding:4px 2px;font-size:13px;line-height:1.55}',
299
+ '.' + CLASS + ' h3{margin:0;font-size:14px;font-weight:600}',
300
+ '.' + CLASS + ' p{margin:0;color:var(--ho-fg-2)}',
301
+ '.' + CLASS + ' label{display:block;font-weight:600;margin-bottom:6px}',
302
+ // 只给插件自己的控件上色:原生 Input 的 <input> 自带外壳的 class(配色由外壳
303
+ // 管),所以这条用 input:not([class]) 把两者分开。
304
+ '.' + CLASS + ' textarea,.' + CLASS + ' input:not([class]){',
305
+ 'width:100%;box-sizing:border-box;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;',
306
+ 'font-size:12px;line-height:1.6;padding:8px 10px;border-radius:8px;',
307
+ 'border:1px solid var(--ho-border);background:var(--ho-field);color:var(--ho-fg);resize:vertical}',
308
+ // 原生 Input 的包装层撑满栏宽,内部输入沿用这份等宽字体(路径/命令列一致的观感)。
309
+ '.' + CLASS + ' .ho-input{width:100%}',
310
+ '.' +
311
+ CLASS +
312
+ ' .ho-input input{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px}',
313
+ '.' + CLASS + ' .ho-row{display:flex;gap:8px;align-items:center}',
314
+ // 只给降级路径自带的按钮加样式:外壳的 Button 自带 hover/active/disabled 与
315
+ // 过渡,再叠一层边框和主色反而跟外壳不一致。
316
+ '.' + CLASS + ' .ho-fallback-btn{',
317
+ 'font:inherit;font-size:13px;padding:6px 14px;border-radius:8px;cursor:pointer;',
318
+ 'border:1px solid var(--ho-border);background:transparent;color:var(--ho-fg)}',
319
+ '.' +
320
+ CLASS +
321
+ ' .ho-fallback-btn.ho-primary{background:var(--ho-accent);color:var(--ho-accent-fg);border-color:transparent}',
322
+ '.' + CLASS + ' .ho-fallback-btn:disabled{opacity:.5;cursor:default}',
323
+ '.' + CLASS + ' .ho-note{font-size:12px;color:var(--ho-fg-2)}',
324
+ // 降级路径的标签与状态点(拿不到外壳 Tag/StateDot 时用插件自己的配色画)。
325
+ '.' +
326
+ CLASS +
327
+ ' .ho-tag{display:inline-flex;align-items:center;padding:1px 8px;border:1px solid var(--ho-border);border-radius:999px;font-size:12px;color:var(--ho-fg-2);white-space:nowrap}',
328
+ '.' + CLASS + ' .ho-status{display:flex;align-items:flex-start;gap:6px}',
329
+ // 失败文案可能很长,外壳 Tag 默认 nowrap,这里放开换行,避免撑破设置栏。
330
+ '.' + CLASS + ' .ho-status-tag{white-space:normal}',
331
+ '.' +
332
+ CLASS +
333
+ ' .ho-dot{display:inline-block;width:10px;height:10px;flex:none;margin-top:5px;border-radius:50%;background:var(--ho-fg-2)}',
334
+ '.' + CLASS + ' .ho-dot-error{background:var(--dsw-alias-state-error-primary,#ef4444)}',
335
+ ].join('')
336
+
337
+ function ensureStyles() {
338
+ var existing = document.getElementById(STYLE_ID)
339
+ if (existing) return existing
340
+ var style = document.createElement('style')
341
+ style.id = STYLE_ID
342
+ style.textContent = CSS
343
+ document.head.appendChild(style)
344
+ return style
345
+ }
346
+
347
+ // ── the settings page ──────────────────────────────────────────────────
348
+ /** @param props - the section owner props plus the plugin context. */
349
+ function SettingsSection(props) {
350
+ var ctx = props.ctx
351
+ var scopeRef = useRef(null)
352
+ if (scopeRef.current === null) scopeRef.current = ctx.settingsScope.bind({ namespace: NS })
353
+ var scope = scopeRef.current
354
+
355
+ var snapState = useState(function () {
356
+ return scope.getSnapshot()
357
+ })
358
+ var snap = snapState[0]
359
+ var setSnap = snapState[1]
360
+
361
+ var draftState = useState({ hooks: '', serialHooks: '', log: '' })
362
+ var draft = draftState[0]
363
+ var setDraft = draftState[1]
364
+
365
+ var dirtyRef = useRef(false)
366
+ var busyState = useState(false)
367
+ var busy = busyState[0]
368
+ var setBusy = busyState[1]
369
+ var errorState = useState('')
370
+ var error = errorState[0]
371
+ var setError = errorState[1]
372
+
373
+ useEffect(
374
+ function () {
375
+ return scope.subscribe(function () {
376
+ setSnap(scope.getSnapshot())
377
+ })
378
+ },
379
+ [scope],
380
+ )
381
+
382
+ // Re-seed the editor from every accepted snapshot, but never over an
383
+ // unsaved edit: the user's typing outranks a background refresh.
384
+ var revision = snap.revision
385
+ useEffect(
386
+ function () {
387
+ if (dirtyRef.current) return
388
+ var value = snap.value || {}
389
+ setDraft({ hooks: toLines(value.hooks), serialHooks: toLines(value.serialHooks), log: value.log || '' })
390
+ },
391
+ [revision, snap.value],
392
+ )
393
+
394
+ /** @param fieldName - settings field to edit; @param next - its new textarea content. */
395
+ function edit(fieldName, next) {
396
+ dirtyRef.current = true
397
+ setDraft(function (prev) {
398
+ var updated = { hooks: prev.hooks, serialHooks: prev.serialHooks, log: prev.log }
399
+ updated[fieldName] = next
400
+ return updated
401
+ })
402
+ }
403
+
404
+ function settle(pending) {
405
+ setBusy(true)
406
+ setError('')
407
+ pending.then(
408
+ function () {
409
+ dirtyRef.current = false
410
+ setBusy(false)
411
+ },
412
+ function (failure) {
413
+ setBusy(false)
414
+ setError(String((failure && failure.message) || failure))
415
+ },
416
+ )
417
+ }
418
+
419
+ function save() {
420
+ var value = snap.value || {}
421
+ var ops = []
422
+ if (!sameLines(value.hooks, draft.hooks)) {
423
+ ops.push({ op: 'set', path: ['hooks'], value: fromLines(draft.hooks) })
424
+ }
425
+ if (!sameLines(value.serialHooks, draft.serialHooks)) {
426
+ ops.push({ op: 'set', path: ['serialHooks'], value: fromLines(draft.serialHooks) })
427
+ }
428
+ if ((value.log || '') !== draft.log) {
429
+ ops.push({ op: 'set', path: ['log'], value: draft.log })
430
+ }
431
+ if (ops.length === 0) return
432
+ settle(scope.mutate(ops))
433
+ }
434
+
435
+ /** Send every field back to the composition layer (`base`). */
436
+ function reset() {
437
+ settle(
438
+ scope.mutate([
439
+ { op: 'unset', path: ['hooks'] },
440
+ { op: 'unset', path: ['serialHooks'] },
441
+ { op: 'unset', path: ['log'] },
442
+ ]),
443
+ )
444
+ }
445
+
446
+ var writable = snap.writable
447
+
448
+ /**
449
+ * @param label - field caption.
450
+ * @param key - settings field name.
451
+ * @param text - current draft content.
452
+ * @param rows - textarea height in rows.
453
+ *
454
+ * The hook lists are multi-line, and the kit's Input renders a single-line
455
+ * <input>, so these keep the plugin's own textarea (same rows, same
456
+ * monospace styling); the single-line log path below uses the shell Input.
457
+ */
458
+ function renderListField(label, key, text, rows) {
459
+ return e(
460
+ 'div',
461
+ { key: key },
462
+ e('label', { htmlFor: CLASS + '-' + key }, label),
463
+ e('textarea', {
464
+ id: CLASS + '-' + key,
465
+ rows: rows,
466
+ spellCheck: false,
467
+ value: text,
468
+ disabled: !writable,
469
+ onChange: function (event) {
470
+ edit(key, event.target.value)
471
+ },
472
+ }),
473
+ )
474
+ }
475
+
476
+ /**
477
+ * @param label - field caption.
478
+ * @param key - settings field name.
479
+ * @param text - current draft content.
480
+ *
481
+ * A one-line field (a file path), so the shell's Input is the honest control.
482
+ */
483
+ function renderLineField(label, key, text) {
484
+ return e(
485
+ 'div',
486
+ { key: key },
487
+ e('label', { htmlFor: CLASS + '-' + key }, label),
488
+ textInput({
489
+ id: CLASS + '-' + key,
490
+ value: text,
491
+ disabled: !writable,
492
+ onChange: function (event) {
493
+ edit(key, event.target.value)
494
+ },
495
+ }),
496
+ )
497
+ }
498
+
499
+ var dirty =
500
+ !sameLines(snap.value && snap.value.hooks, draft.hooks) ||
501
+ !sameLines(snap.value && snap.value.serialHooks, draft.serialHooks) ||
502
+ ((snap.value && snap.value.log) || '') !== draft.log
503
+
504
+ var base = snap.base || {}
505
+
506
+ return e(
507
+ 'div',
508
+ { className: CLASS },
509
+ e('h3', null, t('nav')),
510
+ e('p', null, t('intro')),
511
+ snap.status === 'loading'
512
+ ? e('p', null, t('loading'))
513
+ : snap.status === 'unavailable'
514
+ ? e('p', null, t('unavailable'))
515
+ : e(
516
+ 'div',
517
+ { className: CLASS },
518
+ renderListField(t('fieldHooks'), 'hooks', draft.hooks, 8),
519
+ renderListField(t('fieldSerialHooks'), 'serialHooks', draft.serialHooks, 3),
520
+ renderLineField(t('fieldLog'), 'log', draft.log),
521
+ ),
522
+ e(
523
+ 'p',
524
+ { className: 'ho-note' },
525
+ t('note', {
526
+ hooks: String(Array.isArray(base.hooks) ? base.hooks.length : 0),
527
+ serial: String(Array.isArray(base.serialHooks) ? base.serialHooks.length : 0),
528
+ }),
529
+ ),
530
+ error ? saveFailedStatus(error) : null,
531
+ e(
532
+ 'div',
533
+ { className: 'ho-row' },
534
+ button(
535
+ {
536
+ variant: 'primary',
537
+ disabled: busy || !dirty || !writable,
538
+ onClick: save,
539
+ },
540
+ busy ? t('saving') : t('save'),
541
+ ),
542
+ button({ disabled: busy || !writable, onClick: reset }, t('reset')),
543
+ ),
544
+ writable ? null : e('div', { className: 'ho-status' }, tag('warning', t('readonly'))),
545
+ )
546
+ }
547
+
548
+ /**
549
+ * @param message - the failure text reported by `scope.mutate`.
550
+ * @returns the save-failure status: the shell's StateDot + Tag when the kit is
551
+ * there, the plugin's own chip when it is not.
552
+ */
553
+ function saveFailedStatus(message) {
554
+ return e(
555
+ 'div',
556
+ { className: 'ho-status' },
557
+ dot('error'),
558
+ tag('danger', t('saveFailed', { message: message }), 'ho-status-tag'),
559
+ )
560
+ }
561
+
562
+ // ── plugin ─────────────────────────────────────────────────────────────
563
+ // 'locale' is a required service: without the locale plugin in the
564
+ // composition this plugin does not activate at all, rather than
565
+ // registering a page with no copy. bindLocale still degrades for a
566
+ // partial context (see above).
567
+ var inject = ['slots', 'settingsScope', 'locale']
568
+
569
+ /** @param ctx - the client plugin context. */
570
+ function apply(ctx) {
571
+ bindLocale(ctx)
572
+ var style = ensureStyles()
573
+ ctx.effect(function () {
574
+ return function () {
575
+ style.remove()
576
+ }
577
+ })
578
+ ctx.slots.inject('settings.section', function () {
579
+ return ctx.slots.register(
580
+ {
581
+ name: 'settings.section',
582
+ id: 'hooks-ordering',
583
+ order: 27,
584
+ // A thunk: the shell re-renders the nav and calls it again after a
585
+ // locale switch, so it always answers in the active language.
586
+ label: function () {
587
+ return t('nav')
588
+ },
589
+ },
590
+ function Bound(props) {
591
+ return e(SettingsSection, Object.assign({}, props, { ctx: ctx }))
592
+ },
593
+ )
594
+ })
595
+ }
596
+
597
+ // The browser loader consumes only apply/inject; the rest exists so a Node
598
+ // test can load this file with a stub `window` and assert the contract.
599
+ exports.apply = apply
600
+ exports.inject = inject
601
+ exports.NS = NS
602
+ exports.CLASS = CLASS
603
+ exports.CSS = CSS
604
+ exports.ZH = zh
605
+ exports.EN = en
606
+ // The shell kit, or null when the module table did not hand it over; exported
607
+ // so a Node test can assert which path the render took (UI_ERROR carries the
608
+ // reason when the load failed).
609
+ exports.UI = UI
610
+ exports.UI_ERROR = primitivesError
611
+ exports.SettingsSection = SettingsSection
612
+ exports.saveFailedStatus = saveFailedStatus
613
+ exports.fromLines = fromLines
614
+ exports.toLines = toLines
615
+ exports.sameLines = sameLines
616
+ return module.exports
617
+ },
618
+ })
619
+ })()
@@ -0,0 +1,38 @@
1
+ # The dsh-hooks-ordering bundle patch: mount the waterfall and serial
2
+ # ordering services and take control of the dsh hooks that multiple independent
3
+ # packages contribute to (agent/pre-step, tools/post-execute,
4
+ # system-prompt/assemble, agent/turn-stopping, …).
5
+ #
6
+ # `llm/stream` and `session-telemetry/record` are deliberately NOT in the
7
+ # default set: dsh dispatches both without awaiting, so a participant would turn
8
+ # their return value into a Promise (a broken stream / a silently corrupted
9
+ # telemetry record). They are listed in `syncReturnHooks`, which makes
10
+ # `register()` refuse them with an explicit error; `control()` stays allowed.
11
+ #
12
+ # Apply this layer LAST — after the native/contributing plugins load. The
13
+ # plugin brackets each hook with a single PREPENDED listener; prepending it
14
+ # after the native listeners are registered makes its next() enclose the whole
15
+ # native chain. Mounted earlier, a native plugin that prepends later would land
16
+ # ahead of the bracket and escape ordering (overwrite it). In a dsh profile the
17
+ # user cordis.patch.yml is applied after every bundle layer, so inserting the
18
+ # row there loads it last by construction.
19
+ # Controlling a hook with no registered participants is a transparent
20
+ # pass-through, so this row changes nothing until a plugin registers with
21
+ # before/after.
22
+ #
23
+ # Row order carries no load semantics (activation is service-availability
24
+ # driven); ordering is enforced by the coordinator, not by position. Override
25
+ # `hooks`/`serialHooks` to narrow the controlled set, or set `log` to write the
26
+ # constraint DAG (JSON) on every registration change.
27
+
28
+ - insert:
29
+ - id: hooks-ordering
30
+ # The BARE package name, not the `/dsh` subpath. dsh maps a row's name back
31
+ # to a package to find the browser half (`dsh.client`); a subpath specifier
32
+ # is not a package it can resolve, so the settings page would never appear.
33
+ name: '@zfdx123/dsh-hooks-ordering'
34
+ config:
35
+ # hooks: ['agent/pre-step', 'tools/post-execute']
36
+ # serialHooks: ['agent/turn-stopping']
37
+ # syncReturnHooks: ['llm/stream', 'session-telemetry/record']
38
+ # log: './hooks-ordering-dag.json'