@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
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Shared coordinator machinery for the waterfall and serial hook-ordering
3
+ * services. Both manage, per controlled hook, two ordered participant lists
4
+ * (`front`/`back`) plus the disposer for the installed coordinator(s); they
5
+ * differ only in HOW the coordinator listeners are installed and HOW a phase
6
+ * runs (waterfall wraps the native chain via `next()`; serial runs participants
7
+ * ahead of it with bail short-circuiting). Registration, planning, DAG dumping,
8
+ * and optional file logging are identical, so they live here.
9
+ * @module dsh-hooks-ordering/service-base
10
+ */
11
+
12
+ import { writeFileSync } from 'node:fs'
13
+ import { type Context, Service } from '@deepseek-ai/cordis'
14
+ import { type DagSection, buildDag } from './dag.ts'
15
+ import { type Orderable, topoSort } from './topo-sort.ts'
16
+
17
+ /**
18
+ * Where a participant runs relative to the native hook chain.
19
+ * - `front`: ahead of every native listener (and, for waterfall, the built-in default).
20
+ * - `back`: behind the native chain (waterfall) / best-effort last (serial).
21
+ */
22
+ export type Phase = 'front' | 'back'
23
+
24
+ /** Thrown when registering into, or double-controlling, a hook in an unsupported state. */
25
+ export class HookControlError extends Error {
26
+ /**
27
+ * @param message - the specific control-state violation.
28
+ */
29
+ constructor(message: string) {
30
+ super(`hooks-ordering: ${message}`)
31
+ this.name = 'HookControlError'
32
+ }
33
+ }
34
+
35
+ /** Optional file logging shared by both services. */
36
+ export interface HookOrderingLogConfig {
37
+ /**
38
+ * When set, the constraint DAG (JSON) is written to this file on every
39
+ * registration change, so it always reflects current state. Write failures
40
+ * are reported via `console.warn` and never thrown back into the fiber.
41
+ */
42
+ readonly log?: string
43
+ }
44
+
45
+ /** Per-hook coordinator state: the two ordered phases and the disposer for the installed coordinator(s). */
46
+ export interface ControlledHook<E extends Orderable> {
47
+ readonly front: E[]
48
+ readonly back: E[]
49
+ readonly dispose: () => void
50
+ }
51
+
52
+ /**
53
+ * Common base for the hook-ordering services. Subclasses implement
54
+ * {@link install} (register the coordinator listener(s) for one hook and return
55
+ * their disposer) and expose a typed `register`; everything else is shared.
56
+ * @typeParam E - the participant entry type stored per phase.
57
+ */
58
+ export abstract class HookOrderingBase<E extends Orderable> extends Service {
59
+ protected readonly hooks = new Map<string, ControlledHook<E>>()
60
+ /** File the DAG is logged to on every registration change, if configured. */
61
+ readonly log: string | undefined
62
+
63
+ /**
64
+ * @param ctx - the Cordis context to register the service in.
65
+ * @param name - the service name exposed on `ctx`.
66
+ * @param config - optional `log` file for the constraint DAG. May be `null`:
67
+ * a loader row whose `config:` key holds only comments parses as YAML null.
68
+ */
69
+ constructor(ctx: Context, name: string, config: HookOrderingLogConfig | null = {}) {
70
+ super(ctx, name)
71
+ this.log = config?.log
72
+ }
73
+
74
+ /**
75
+ * Install the coordinator listener(s) for one hook, capturing its `front`/
76
+ * `back` lists, and return a disposer removing them. Subclass-specific.
77
+ */
78
+ protected abstract install(hook: string, front: E[], back: E[]): () => void
79
+
80
+ /**
81
+ * Take control of a hook by installing the coordinator listener(s). Called
82
+ * once per hook; participants then register into it. Controlling twice is
83
+ * rejected — a second coordinator would reintroduce the race this removes.
84
+ *
85
+ * @param hook - the event name to control.
86
+ * @returns a disposer that removes the coordinator(s) and forgets the hook.
87
+ * @throws {HookControlError} when the hook is already controlled.
88
+ */
89
+ control(hook: string): () => void {
90
+ if (this.hooks.has(hook)) throw new HookControlError(`hook ${JSON.stringify(hook)} is already controlled`)
91
+
92
+ const front: E[] = []
93
+ const back: E[] = []
94
+ const removeListeners = this.install(hook, front, back)
95
+
96
+ const dispose = (): void => {
97
+ removeListeners()
98
+ this.hooks.delete(hook)
99
+ this.refreshLog()
100
+ }
101
+ this.hooks.set(hook, { front, back, dispose })
102
+ this.refreshLog()
103
+ return dispose
104
+ }
105
+
106
+ /**
107
+ * Shared registration: push the entry into the hook's phase list as a fiber
108
+ * effect, refreshing the DAG log on add and remove.
109
+ *
110
+ * @returns a disposer that unregisters this participant.
111
+ * @throws {HookControlError} when the hook has not been controlled.
112
+ */
113
+ protected registerEntry(hook: string, phase: Phase, entry: E): () => void {
114
+ const controlled = this.hooks.get(hook)
115
+ if (controlled === undefined)
116
+ throw new HookControlError(
117
+ `hook ${JSON.stringify(hook)} is not controlled; call control(${JSON.stringify(hook)}) first`,
118
+ )
119
+ const list = controlled[phase]
120
+ return this.ctx.effect(
121
+ () => {
122
+ list.push(entry)
123
+ this.refreshLog()
124
+ return () => {
125
+ const at = list.indexOf(entry)
126
+ if (at >= 0) list.splice(at, 1)
127
+ this.refreshLog()
128
+ }
129
+ },
130
+ `${this.name}.register(${JSON.stringify(hook)}, ${JSON.stringify(phase)}, ${JSON.stringify(entry.name)})`,
131
+ )
132
+ }
133
+
134
+ /**
135
+ * Compute the ordered participant names for one hook phase without running
136
+ * them. Reflects current registrations; useful for tests and diagnostics.
137
+ *
138
+ * @throws {HookControlError} when the hook has not been controlled.
139
+ */
140
+ plan(hook: string, phase: Phase): string[] {
141
+ const controlled = this.hooks.get(hook)
142
+ if (controlled === undefined) throw new HookControlError(`hook ${JSON.stringify(hook)} is not controlled`)
143
+ return topoSort(controlled[phase]).map((entry) => entry.name)
144
+ }
145
+
146
+ /**
147
+ * Serialize the constraint DAG of every controlled hook (all phases) as
148
+ * pretty-printed JSON. Pure read: does not write the log file or throw on
149
+ * cycles — the graph is most useful precisely when constraints conflict.
150
+ */
151
+ dumpDag(): string {
152
+ return JSON.stringify(buildDag(this.dagSections()), null, 2)
153
+ }
154
+
155
+ /** Flatten every controlled hook into its `front`/`back` DAG sections. */
156
+ protected dagSections(): DagSection[] {
157
+ const sections: DagSection[] = []
158
+ for (const [hook, controlled] of this.hooks) {
159
+ sections.push({ hook, phase: 'front', entries: controlled.front })
160
+ sections.push({ hook, phase: 'back', entries: controlled.back })
161
+ }
162
+ return sections
163
+ }
164
+
165
+ /**
166
+ * Best-effort write of the current DAG to the configured `log` file. Never
167
+ * throws: a failure (e.g. unwritable path) is reported via `console.warn`,
168
+ * because this runs inside registration effects and disposers, where an
169
+ * exception would corrupt fiber teardown.
170
+ */
171
+ protected refreshLog(): void {
172
+ if (this.log === undefined) return
173
+ try {
174
+ writeFileSync(this.log, `${this.dumpDag()}\n`)
175
+ } catch (error) {
176
+ console.warn(`hooks-ordering: failed to write DAG log to ${JSON.stringify(this.log)}:`, error)
177
+ }
178
+ }
179
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The dsh settings namespace for this plugin: the three fields a user can edit
3
+ * from the Settings page instead of a `cordis.patch.yml` row.
4
+ *
5
+ * Two constraints are load-bearing and easy to get wrong:
6
+ *
7
+ * - The schema handed to `settings.register` must be a **callable** schemastery
8
+ * schema. dsh resolves a namespace by calling `schema(merged)` and reads
9
+ * `schema.toJSON()` for the form, so a plain object throws
10
+ * `schema is not a function` — during plugin assembly, which takes the whole
11
+ * profile down rather than failing one form.
12
+ * - That is also why registration is wrapped in `try/catch` here: an optional
13
+ * settings form must never be able to stop the harness from booting.
14
+ *
15
+ * @module dsh-hooks-ordering/settings
16
+ */
17
+
18
+ import type { Context } from '@deepseek-ai/cordis'
19
+ import Schema from '@deepseek-ai/schemastery'
20
+
21
+ /** The three fields this plugin exposes for configuration. */
22
+ export interface HooksOrderingSettings {
23
+ /** Waterfall hooks to control; `[]` disables the waterfall service entirely. */
24
+ readonly hooks: readonly string[]
25
+ /** Serial hooks to control; `[]` disables the serial service entirely. */
26
+ readonly serialHooks: readonly string[]
27
+ /** Constraint-DAG log file; the empty string means "do not log". */
28
+ readonly log: string
29
+ }
30
+
31
+ /** Namespace name — dsh requires a lowercase hyphenated identifier. */
32
+ export const SETTINGS_NS = 'hooks-ordering'
33
+
34
+ /**
35
+ * Schema for {@link SETTINGS_NS}.
36
+ *
37
+ * Every field defaults to its "off" value. The *meaningful* defaults — the
38
+ * hooks this plugin controls when nothing overrides them — are supplied by the
39
+ * caller as the composition `base` layer (see {@link resolveSettings}), so a
40
+ * namespace the user has never touched still resolves to a complete object.
41
+ */
42
+ export const HooksOrderingSettingsSchema = Schema.object({
43
+ hooks: Schema.array(Schema.string()).default([]),
44
+ serialHooks: Schema.array(Schema.string()).default([]),
45
+ log: Schema.string().default(''),
46
+ })
47
+
48
+ /**
49
+ * The slice of dsh's `settings` service this plugin uses.
50
+ *
51
+ * Structural rather than imported from `@deepseek-ai/dsh-settings`: it describes
52
+ * exactly the contract this plugin relies on without adding a build-time
53
+ * dependency on a dsh package, whose internals are still 0.1.x-rc.
54
+ */
55
+ export interface SettingsServiceLike {
56
+ /**
57
+ * @param ns - the namespace to register; must be a lowercase hyphenated identifier.
58
+ * @param schema - a callable schemastery schema, not a plain object.
59
+ * @param options - `base` is the composition layer, `applies` says whether an
60
+ * edit takes effect live or needs a restart.
61
+ * @returns a scope whose `get()` is the resolved value (schema defaults, then `base`, then the user layer).
62
+ */
63
+ register(
64
+ ns: string,
65
+ schema: unknown,
66
+ options?: { readonly base?: unknown; readonly applies?: 'live' | 'restart' },
67
+ ): { get(): HooksOrderingSettings }
68
+ }
69
+
70
+ /**
71
+ * Register the settings namespace and read its resolved value.
72
+ *
73
+ * `base` is the composition layer — what the row (or the built-in defaults)
74
+ * asks for — so the user layer edits *over* it and a reset returns to it rather
75
+ * than to an empty form.
76
+ *
77
+ * `applies: 'restart'` is deliberate. Changing `hooks`/`serialHooks` means
78
+ * installing or removing bracket listeners on live hooks, and the coordinator
79
+ * has no public "release one hook" operation; a restart applies the change
80
+ * cleanly instead of re-wiring the dispatch chain mid-flight.
81
+ *
82
+ * @param ctx - the Cordis context to look the service up on.
83
+ * @param base - the composition layer to register as the base value.
84
+ * @returns the resolved settings, or `undefined` when the provider is missing
85
+ * (which `inject: ['settings']` rules out in dsh — the guard is defensive) or
86
+ * registration failed — the caller then uses `base`.
87
+ */
88
+ export function resolveSettings(ctx: Context, base: HooksOrderingSettings): HooksOrderingSettings | undefined {
89
+ const settings = ctx.get('settings') as SettingsServiceLike | undefined
90
+ if (settings === undefined) return undefined
91
+ try {
92
+ return settings.register(SETTINGS_NS, HooksOrderingSettingsSchema, { base, applies: 'restart' }).get()
93
+ } catch (error) {
94
+ console.warn('hooks-ordering: settings registration failed; falling back to the composition config:', error)
95
+ return undefined
96
+ }
97
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Deterministic topological sort for named entries carrying `before`/`after`
3
+ * ordering constraints. Zero runtime dependencies: the sort is a pure function
4
+ * of its input, independent of any Cordis context or plugin load order.
5
+ * @module dsh-hooks-ordering/topo-sort
6
+ */
7
+
8
+ /** An entry that can be ordered relative to others by name. */
9
+ export interface Orderable {
10
+ /** Unique identifier within one hook phase. Referenced by other entries' `before`/`after`. */
11
+ readonly name: string
12
+ /** Names this entry must run before. An unknown name imposes no constraint (see {@link topoSort}). */
13
+ readonly before?: readonly string[]
14
+ /** Names this entry must run after. An unknown name imposes no constraint (see {@link topoSort}). */
15
+ readonly after?: readonly string[]
16
+ }
17
+
18
+ /** Thrown when `before`/`after` constraints form a cycle, so no total order exists. */
19
+ export class OrderingCycleError extends Error {
20
+ /** The names still blocked when the sort stalled — the entries on and behind the cycle. */
21
+ readonly cycle: readonly string[]
22
+ /**
23
+ * @param cycle - the names left unresolved by the cycle.
24
+ */
25
+ constructor(cycle: readonly string[]) {
26
+ super(`hooks-ordering: constraints form a cycle among: ${cycle.join(', ')}`)
27
+ this.name = 'OrderingCycleError'
28
+ this.cycle = cycle
29
+ }
30
+ }
31
+
32
+ /** Thrown when two entries in one phase share a `name`, which would make references ambiguous. */
33
+ export class DuplicateNameError extends Error {
34
+ /** The duplicated entry name. */
35
+ readonly duplicate: string
36
+ /**
37
+ * @param duplicate - the name registered more than once.
38
+ */
39
+ constructor(duplicate: string) {
40
+ super(`hooks-ordering: duplicate entry name ${JSON.stringify(duplicate)}`)
41
+ this.name = 'DuplicateNameError'
42
+ this.duplicate = duplicate
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Order entries so every `after` target precedes the entry and every `before`
48
+ * target follows it. Ties (entries with no constraint between them) keep their
49
+ * input order, so the result is stable and independent of registration timing.
50
+ *
51
+ * An unknown name in `before`/`after` (no registered entry owns it) imposes no
52
+ * constraint rather than failing: cross-vendor entries reference optional peers
53
+ * that may not be loaded, so a missing target is a legitimate no-op, not a
54
+ * misconfiguration. A cycle among present entries is fatal — it has no valid
55
+ * order — and throws {@link OrderingCycleError}.
56
+ *
57
+ * @param entries - the entries to order; each `name` must be unique.
58
+ * @returns a new array of the same entries in a constraint-respecting order.
59
+ * @throws {DuplicateNameError} when two entries share a `name`.
60
+ * @throws {OrderingCycleError} when present entries form an ordering cycle.
61
+ */
62
+ export function topoSort<T extends Orderable>(entries: readonly T[]): T[] {
63
+ const index = new Map<string, number>()
64
+ entries.forEach((entry, position) => {
65
+ if (index.has(entry.name)) throw new DuplicateNameError(entry.name)
66
+ index.set(entry.name, position)
67
+ })
68
+
69
+ // successors[i] = entries that must run after entry i. indegree[i] = number
70
+ // of entries that must run before entry i.
71
+ const successors: number[][] = entries.map(() => [])
72
+ const indegree: number[] = entries.map(() => 0)
73
+
74
+ const addEdge = (fromName: string, toName: string): void => {
75
+ const from = index.get(fromName)
76
+ const to = index.get(toName)
77
+ // Unknown endpoint: the referenced peer is not loaded, so no ordering
78
+ // relation exists to enforce.
79
+ if (from === undefined || to === undefined || from === to) return
80
+ successors[from]!.push(to)
81
+ indegree[to]!++
82
+ }
83
+
84
+ entries.forEach((entry) => {
85
+ for (const target of entry.after ?? []) addEdge(target, entry.name)
86
+ for (const target of entry.before ?? []) addEdge(entry.name, target)
87
+ })
88
+
89
+ // Kahn's algorithm. The ready set holds nodes with no unmet predecessor; it
90
+ // is kept in ascending input position so equal candidates emit in input
91
+ // order, giving a deterministic, stable result. Per-phase entry counts are
92
+ // small, so a sort on each insertion is simpler than a hand-rolled heap.
93
+ const ready: number[] = []
94
+ const pushReady = (node: number): void => {
95
+ ready.push(node)
96
+ ready.sort((left, right) => left - right)
97
+ }
98
+ indegree.forEach((count, node) => {
99
+ if (count === 0) pushReady(node)
100
+ })
101
+
102
+ const result: T[] = []
103
+ while (ready.length > 0) {
104
+ const node = ready.shift()!
105
+ result.push(entries[node]!)
106
+ for (const successor of successors[node]!) {
107
+ if (--indegree[successor]! === 0) pushReady(successor)
108
+ }
109
+ }
110
+
111
+ if (result.length !== entries.length) {
112
+ // The entries still carrying an unmet predecessor are exactly those on or
113
+ // behind the cycle; naming them all is more useful than one arbitrary loop.
114
+ const blocked = entries.filter((_, node) => indegree[node]! > 0).map((entry) => entry.name)
115
+ throw new OrderingCycleError(blocked)
116
+ }
117
+ return result
118
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `HookOrdering` — deterministic before/after ordering for Cordis waterfall
3
+ * hooks whose participants are contributed by independent plugins.
4
+ *
5
+ * Cordis runs waterfall listeners in registration order (array position, with
6
+ * `prepend` as the only lever), and registration order is driven by
7
+ * inject-dependency activation — non-deterministic between unrelated plugins.
8
+ * A plugin therefore cannot reliably say "run me after that other plugin",
9
+ * especially across vendors that do not depend on each other.
10
+ *
11
+ * This service brackets a chosen waterfall hook with ONE prepended listener
12
+ * that exploits the onion model: code before its `next()` runs ahead of the
13
+ * whole native chain, code after runs behind it. Participants register into
14
+ * this coordinator instead of the raw hook, declaring `before`/`after` names,
15
+ * and the coordinator runs them in a stable topological order it fully
16
+ * controls. The serial-dispatch twin lives in `./serial.ts`.
17
+ *
18
+ * @module dsh-hooks-ordering/waterfall
19
+ */
20
+
21
+ import { type Context } from '@deepseek-ai/cordis'
22
+ import { HookControlError, HookOrderingBase, type HookOrderingLogConfig, type Phase } from './service-base.ts'
23
+ import { type Orderable, topoSort } from './topo-sort.ts'
24
+
25
+ /**
26
+ * One ordered participant in a controlled waterfall hook phase.
27
+ * @typeParam A - the hook's payload argument tuple (the dispatched args without Cordis' trailing `next`).
28
+ */
29
+ export interface HookEntry<A extends readonly unknown[] = readonly unknown[]> extends Orderable {
30
+ /** Run this participant with the hook payload. Awaited before the phase proceeds. */
31
+ readonly run: (...args: A) => void | Promise<void>
32
+ }
33
+
34
+ /** Configuration for the {@link HookOrdering} service. */
35
+ export interface HookOrderingConfig extends HookOrderingLogConfig {
36
+ /**
37
+ * Hook names whose dispatch return value the CALLER consumes without
38
+ * awaiting — dsh's `llm/stream` (an AsyncIterable iterated directly) and
39
+ * `session-telemetry/record` (a record handed straight to a backend) are the
40
+ * two in the shipped build.
41
+ *
42
+ * A participant cannot be ordered on such a hook. The bracket only preserves
43
+ * the native return value while both phases are empty; with a participant it
44
+ * must await, so it returns a Promise where the caller expects a value — a
45
+ * broken stream or a corrupted record, silently. {@link HookOrdering.register}
46
+ * therefore refuses those hooks, and *controlling* them stays allowed: the
47
+ * bracket is a transparent pass-through and may become useful the day the
48
+ * host starts awaiting that hook (then pass `[]` here, or a set without it).
49
+ */
50
+ readonly syncReturnHooks?: readonly string[]
51
+ }
52
+
53
+ declare module '@deepseek-ai/cordis' {
54
+ interface Context {
55
+ hooksOrdering: HookOrdering
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Coordinator service registered at `ctx.hooksOrdering`. One instance controls
61
+ * any number of waterfall hooks; each controlled hook owns one bracket listener
62
+ * and two ordered participant lists.
63
+ */
64
+ export class HookOrdering extends HookOrderingBase<HookEntry> {
65
+ /**
66
+ * Hooks that must never carry a participant: their return value is consumed
67
+ * synchronously by the host, so the bracket's await would corrupt it.
68
+ * @see HookOrderingConfig.syncReturnHooks
69
+ */
70
+ private readonly syncReturnHooks: ReadonlySet<string>
71
+
72
+ /**
73
+ * @param ctx - the Cordis context to register the service in.
74
+ * @param config - optional `log` file for the constraint DAG (JSON) and the
75
+ * `syncReturnHooks` admission list; `null` means defaults.
76
+ */
77
+ constructor(ctx: Context, config: HookOrderingConfig | null = {}) {
78
+ super(ctx, 'hooksOrdering', config)
79
+ this.syncReturnHooks = new Set(config?.syncReturnHooks ?? [])
80
+ }
81
+
82
+ /**
83
+ * Install the single bracket listener. `prepend` places it first among
84
+ * current listeners so its `next()` encloses the rest of the native chain;
85
+ * the listener is an effect on this service's fiber and is removed with it or
86
+ * with the disposer {@link control} returns.
87
+ *
88
+ * The bracket is deliberately NOT `async` at its outermost level. Cordis'
89
+ * `waterfall` returns the outermost listener's value *synchronously*, so an
90
+ * `async` bracket would turn the hook's return value into a Promise for every
91
+ * caller — breaking any hook whose result the caller consumes without
92
+ * awaiting (dsh's `llm/stream` returns an AsyncIterable, and wraps with
93
+ * "not async iterable"). While both phases are empty there is nothing to
94
+ * order, so the chain is handed straight through and the hook's return type
95
+ * is exactly what it was before control was taken.
96
+ */
97
+ protected install(hook: string, front: HookEntry[], back: HookEntry[]): () => void {
98
+ const bracket = (...args: unknown[]): unknown => {
99
+ const next = args[args.length - 1] as () => unknown
100
+ // Transparent fast path: no participants, so no await is needed and the
101
+ // native return value (promise or not) passes through unchanged.
102
+ if (front.length === 0 && back.length === 0) return next()
103
+ const payload = args.slice(0, -1)
104
+ // With participants the phases must be awaited, so the bracket can only
105
+ // return a promise from here on — inherent to ordering, not a choice.
106
+ return (async (): Promise<unknown> => {
107
+ await runPhase(front, payload)
108
+ const result = await next()
109
+ await runPhase(back, payload)
110
+ return result
111
+ })()
112
+ }
113
+ return this.ctx.on(hook as never, bracket as never, { prepend: true })
114
+ }
115
+
116
+ /**
117
+ * Register a participant into a controlled hook phase.
118
+ *
119
+ * @param hook - the controlled waterfall event name.
120
+ * @param phase - `front` to run ahead of the native chain, `back` to run behind it.
121
+ * @param entry - the participant, with optional `before`/`after` names and its `run` callback.
122
+ * @returns a disposer that unregisters this participant.
123
+ * @throws {HookControlError} when the hook has not been {@link control}led, or
124
+ * when it is declared in `syncReturnHooks` — there the participant would work
125
+ * but the host's return value would change type, which is the worse failure.
126
+ */
127
+ register<A extends readonly unknown[] = readonly unknown[]>(
128
+ hook: string,
129
+ phase: Phase,
130
+ entry: HookEntry<A>,
131
+ ): () => void {
132
+ if (this.syncReturnHooks.has(hook)) {
133
+ throw new HookControlError(
134
+ `hook ${JSON.stringify(hook)} returns to a caller that does not await it, so ordering participants would turn its return value into a Promise and break that caller; ` +
135
+ `remove ${JSON.stringify(hook)} from syncReturnHooks only once the host awaits it`,
136
+ )
137
+ }
138
+ return this.registerEntry(hook, phase, entry as unknown as HookEntry)
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Run one phase's participants in stable topological order, awaiting each.
144
+ * @param entries - the phase's registered participants.
145
+ * @param payload - the hook payload passed to each `run` callback.
146
+ */
147
+ async function runPhase(entries: readonly HookEntry[], payload: readonly unknown[]): Promise<void> {
148
+ for (const entry of topoSort(entries)) {
149
+ await entry.run(...payload)
150
+ }
151
+ }
152
+
153
+ export default HookOrdering