@fractalsynapse/dsh-chapters 0.0.0-stage → 0.2.20

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 (195) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +76 -2
  3. package/cordis.patch.yml +363 -0
  4. package/docs/architecture.md +692 -0
  5. package/docs/autorun-guide.md +248 -0
  6. package/docs/backlog.md +2022 -0
  7. package/docs/contract.md +938 -0
  8. package/docs/development.md +990 -0
  9. package/docs/host-compaction-seam.md +225 -0
  10. package/docs/host-protected-head-ask.md +80 -0
  11. package/docs/knowledge-repo.md +1332 -0
  12. package/docs/plans-carriage.md +571 -0
  13. package/docs/project-state.md +642 -0
  14. package/docs/provider.md +98 -0
  15. package/docs/superpowers/plans/2026-10-17-chapters-save.md +198 -0
  16. package/docs/superpowers/plans/2026-11-02-chapter-removal.md +151 -0
  17. package/docs/superpowers/specs/2026-11-02-chapter-removal-design.md +115 -0
  18. package/docs/verify.md +561 -0
  19. package/lib/archive.d.ts +161 -0
  20. package/lib/archive.js +229 -0
  21. package/lib/archive.js.map +1 -0
  22. package/lib/arrival.d.ts +23 -0
  23. package/lib/arrival.js +125 -0
  24. package/lib/arrival.js.map +1 -0
  25. package/lib/artifact-query.d.ts +62 -0
  26. package/lib/artifact-query.js +187 -0
  27. package/lib/artifact-query.js.map +1 -0
  28. package/lib/attach.d.ts +46 -0
  29. package/lib/attach.js +120 -0
  30. package/lib/attach.js.map +1 -0
  31. package/lib/auto-save.d.ts +145 -0
  32. package/lib/auto-save.js +334 -0
  33. package/lib/auto-save.js.map +1 -0
  34. package/lib/aux-message.d.ts +17 -0
  35. package/lib/aux-message.js +20 -0
  36. package/lib/aux-message.js.map +1 -0
  37. package/lib/client.js +1419 -0
  38. package/lib/client.js.map +1 -0
  39. package/lib/codegraph.d.ts +76 -0
  40. package/lib/codegraph.js +190 -0
  41. package/lib/codegraph.js.map +1 -0
  42. package/lib/commands.d.ts +52 -0
  43. package/lib/commands.js +946 -0
  44. package/lib/commands.js.map +1 -0
  45. package/lib/compose.d.ts +30 -0
  46. package/lib/compose.js +185 -0
  47. package/lib/compose.js.map +1 -0
  48. package/lib/continue-core.d.ts +245 -0
  49. package/lib/continue-core.js +405 -0
  50. package/lib/continue-core.js.map +1 -0
  51. package/lib/diag.d.ts +55 -0
  52. package/lib/diag.js +186 -0
  53. package/lib/diag.js.map +1 -0
  54. package/lib/engine-core.d.ts +301 -0
  55. package/lib/engine-core.js +531 -0
  56. package/lib/engine-core.js.map +1 -0
  57. package/lib/engine-note.d.ts +35 -0
  58. package/lib/engine-note.js +32 -0
  59. package/lib/engine-note.js.map +1 -0
  60. package/lib/engine.d.ts +329 -0
  61. package/lib/engine.js +1179 -0
  62. package/lib/engine.js.map +1 -0
  63. package/lib/enrich-queue.d.ts +66 -0
  64. package/lib/enrich-queue.js +126 -0
  65. package/lib/enrich-queue.js.map +1 -0
  66. package/lib/enrich-store.d.ts +31 -0
  67. package/lib/enrich-store.js +103 -0
  68. package/lib/enrich-store.js.map +1 -0
  69. package/lib/enrich-wire.d.ts +163 -0
  70. package/lib/enrich-wire.js +353 -0
  71. package/lib/enrich-wire.js.map +1 -0
  72. package/lib/enrich.d.ts +102 -0
  73. package/lib/enrich.js +267 -0
  74. package/lib/enrich.js.map +1 -0
  75. package/lib/gitops.d.ts +107 -0
  76. package/lib/gitops.js +539 -0
  77. package/lib/gitops.js.map +1 -0
  78. package/lib/index.d.ts +241 -0
  79. package/lib/index.js +1162 -0
  80. package/lib/index.js.map +1 -0
  81. package/lib/indexing.d.ts +98 -0
  82. package/lib/indexing.js +223 -0
  83. package/lib/indexing.js.map +1 -0
  84. package/lib/injections.d.ts +90 -0
  85. package/lib/injections.js +165 -0
  86. package/lib/injections.js.map +1 -0
  87. package/lib/last-resort.d.ts +24 -0
  88. package/lib/last-resort.js +27 -0
  89. package/lib/last-resort.js.map +1 -0
  90. package/lib/log-shape.d.ts +38 -0
  91. package/lib/log-shape.js +93 -0
  92. package/lib/log-shape.js.map +1 -0
  93. package/lib/models-command.d.ts +74 -0
  94. package/lib/models-command.js +100 -0
  95. package/lib/models-command.js.map +1 -0
  96. package/lib/notice.d.ts +74 -0
  97. package/lib/notice.js +92 -0
  98. package/lib/notice.js.map +1 -0
  99. package/lib/plans.d.ts +154 -0
  100. package/lib/plans.js +315 -0
  101. package/lib/plans.js.map +1 -0
  102. package/lib/preset.d.ts +186 -0
  103. package/lib/preset.js +212 -0
  104. package/lib/preset.js.map +1 -0
  105. package/lib/prompt-keys.d.ts +7 -0
  106. package/lib/prompt-keys.js +8 -0
  107. package/lib/prompt-keys.js.map +1 -0
  108. package/lib/prompt-loop.d.ts +73 -0
  109. package/lib/prompt-loop.js +147 -0
  110. package/lib/prompt-loop.js.map +1 -0
  111. package/lib/prompt-push.d.ts +47 -0
  112. package/lib/prompt-push.js +115 -0
  113. package/lib/prompt-push.js.map +1 -0
  114. package/lib/prompt-runner.d.ts +48 -0
  115. package/lib/prompt-runner.js +90 -0
  116. package/lib/prompt-runner.js.map +1 -0
  117. package/lib/prompt-state.d.ts +142 -0
  118. package/lib/prompt-state.js +101 -0
  119. package/lib/prompt-state.js.map +1 -0
  120. package/lib/prompt.d.ts +270 -0
  121. package/lib/prompt.js +408 -0
  122. package/lib/prompt.js.map +1 -0
  123. package/lib/provider.d.ts +44 -0
  124. package/lib/provider.js +66 -0
  125. package/lib/provider.js.map +1 -0
  126. package/lib/redact.d.ts +24 -0
  127. package/lib/redact.js +85 -0
  128. package/lib/redact.js.map +1 -0
  129. package/lib/registry.d.ts +187 -0
  130. package/lib/registry.js +235 -0
  131. package/lib/registry.js.map +1 -0
  132. package/lib/removal.d.ts +159 -0
  133. package/lib/removal.js +556 -0
  134. package/lib/removal.js.map +1 -0
  135. package/lib/render.d.ts +97 -0
  136. package/lib/render.js +530 -0
  137. package/lib/render.js.map +1 -0
  138. package/lib/repair-run.d.ts +76 -0
  139. package/lib/repair-run.js +506 -0
  140. package/lib/repair-run.js.map +1 -0
  141. package/lib/repair.d.ts +208 -0
  142. package/lib/repair.js +357 -0
  143. package/lib/repair.js.map +1 -0
  144. package/lib/repo.d.ts +78 -0
  145. package/lib/repo.js +184 -0
  146. package/lib/repo.js.map +1 -0
  147. package/lib/rules-commands.d.ts +46 -0
  148. package/lib/rules-commands.js +379 -0
  149. package/lib/rules-commands.js.map +1 -0
  150. package/lib/rules.d.ts +267 -0
  151. package/lib/rules.js +478 -0
  152. package/lib/rules.js.map +1 -0
  153. package/lib/save-core.d.ts +72 -0
  154. package/lib/save-core.js +220 -0
  155. package/lib/save-core.js.map +1 -0
  156. package/lib/search.d.ts +112 -0
  157. package/lib/search.js +282 -0
  158. package/lib/search.js.map +1 -0
  159. package/lib/session-rules.d.ts +222 -0
  160. package/lib/session-rules.js +454 -0
  161. package/lib/session-rules.js.map +1 -0
  162. package/lib/signature.d.ts +45 -0
  163. package/lib/signature.js +199 -0
  164. package/lib/signature.js.map +1 -0
  165. package/lib/store.d.ts +156 -0
  166. package/lib/store.js +368 -0
  167. package/lib/store.js.map +1 -0
  168. package/lib/sync.d.ts +220 -0
  169. package/lib/sync.js +810 -0
  170. package/lib/sync.js.map +1 -0
  171. package/lib/tools.d.ts +113 -0
  172. package/lib/tools.js +1090 -0
  173. package/lib/tools.js.map +1 -0
  174. package/lib/treedx/client.d.ts +57 -0
  175. package/lib/treedx/client.js +82 -0
  176. package/lib/treedx/client.js.map +1 -0
  177. package/lib/treedx/provider.d.ts +39 -0
  178. package/lib/treedx/provider.js +479 -0
  179. package/lib/treedx/provider.js.map +1 -0
  180. package/lib/treedx/state.d.ts +27 -0
  181. package/lib/treedx/state.js +65 -0
  182. package/lib/treedx/state.js.map +1 -0
  183. package/lib/types.d.ts +210 -0
  184. package/lib/types.js +31 -0
  185. package/lib/types.js.map +1 -0
  186. package/lib/version.d.ts +9 -0
  187. package/lib/version.js +10 -0
  188. package/lib/version.js.map +1 -0
  189. package/lib/vocabulary.d.ts +41 -0
  190. package/lib/vocabulary.js +118 -0
  191. package/lib/vocabulary.js.map +1 -0
  192. package/lib/warnings.d.ts +10 -0
  193. package/lib/warnings.js +18 -0
  194. package/lib/warnings.js.map +1 -0
  195. package/package.json +151 -4
package/lib/client.js ADDED
@@ -0,0 +1,1419 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@fractalsynapse/dsh-chapters",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+ // src/client/tooltip.ts
9
+ /**
10
+ * The host's Tooltip seat, resolved OPTIONALLY (#194-compat — measured on 0.2.1-rc.5, not guessed):
11
+ *
12
+ * `@deepseek-ai/dsh-client-ui-primitives` exists in the 0.2.0-rc.2 host tree and is ABSENT from the
13
+ * entire 0.2.1-rc.5 install tree — no successor package exports `Tooltip`. Our bundle's static
14
+ * import becomes a LAZY require in the built CJS: the bundle loads and components run, but the
15
+ * first render that touches `Tooltip` throws, and React's error boundary swallows the subtree
16
+ * silently. Measured symptoms on 0.2.1: door registration fully healthy (inject returned a
17
+ * disposer; the factory ran; both register effects are functions) yet the assistant-actions region
18
+ * renders ZERO entries, and the dock renders no row — because both wrap their buttons in Tooltip.
19
+ *
20
+ * The fix keeps one resolution point and one fallback (#59 — never per-component try/catch):
21
+ * probe the module once; when it is absent, buttons carry the platform's own `title` tooltip, so
22
+ * hover text survives on every host and the fancy tooltip survives where it exists.
23
+ */
24
+ /**
25
+ * The tooltip recipe, field for field the goal bar's own (GoalBar.tsx:101-142: `portal`,
26
+ * `side="bottom"`, `delayMs={500}`) — the shipped occupant of the same dock strip, whose tooltips
27
+ * are NOT covered by the composer. `portal` is the load-bearing one: it renders the popup at
28
+ * document level instead of inside our dock's stacking context, which is exactly the maintainer's
29
+ * report (#210: "the hoverover tooltips are covered by the main chat input container"). Our old
30
+ * compile-time types rejected portal/delayMs at the overload; the runtime host always accepted
31
+ * them, and since the seat is typed by us, the recipe is pinned by a row instead of by luck.
32
+ */
33
+ function tooltipPropsFor(label) {
34
+ return {
35
+ label,
36
+ side: "bottom",
37
+ portal: true,
38
+ delayMs: 500
39
+ };
40
+ }
41
+ let resolved;
42
+ function resolveTooltip() {
43
+ if (resolved !== void 0) return resolved;
44
+ resolved = null;
45
+ try {
46
+ if (typeof require === "function") {
47
+ const T = require("@deepseek-ai/dsh-client-ui-primitives")?.Tooltip;
48
+ if (typeof T === "function" || typeof T === "object") resolved = T;
49
+ }
50
+ } catch {
51
+ resolved = null;
52
+ }
53
+ return resolved;
54
+ }
55
+ /** Wrap in the host Tooltip when it exists; otherwise return the child (which must carry `title`). */
56
+ function withTooltip(label, child) {
57
+ const T = resolveTooltip();
58
+ return T !== null ? (0, react.createElement)(T, {
59
+ ...tooltipPropsFor(label),
60
+ children: child
61
+ }) : child;
62
+ }
63
+ /** The native-tooltip attribute, present ONLY when the host seat is absent — no double tooltips. */
64
+ function nativeTitle(label) {
65
+ return resolveTooltip() === null ? { title: label } : {};
66
+ }
67
+ // src/prompt-keys.ts
68
+ /**
69
+ * Identifiers shared by BOTH planes of the push transport (#194). One module, one spelling: the
70
+ * server registers the projection under this key and the client reads `useProjection(key)` — two
71
+ * literals in two planes would be exactly the drift #59 forbids. No node imports (the client
72
+ * compiles this), no DOM (the server imports this).
73
+ */
74
+ const PROMPT_PROJECTION_KEY = "chaptersPrompt";
75
+ // src/client/prompt-bar.ts
76
+ function initialBarState(autorun = "disarmed") {
77
+ return {
78
+ status: "off",
79
+ text: null,
80
+ turnSeq: null,
81
+ error: null,
82
+ note: "",
83
+ intent: "worker-step",
84
+ goalPhase: "none",
85
+ goal: null,
86
+ autorun,
87
+ expanded: false,
88
+ running: false,
89
+ hiddenTurnSeq: null
90
+ };
91
+ }
92
+ function promptBarReducer(state, ev) {
93
+ if (ev.t === "note") return {
94
+ ...state,
95
+ note: ev.note
96
+ };
97
+ const next = baseBarReducer(state, ev);
98
+ return ev.t === "polled" && (next.turnSeq !== state.turnSeq || next.text !== state.text) ? {
99
+ ...next,
100
+ note: ""
101
+ } : next;
102
+ }
103
+ function baseBarReducer(state, ev) {
104
+ switch (ev.t) {
105
+ case "polled":
106
+ if (ev.status === "off") return {
107
+ ...state,
108
+ status: "off",
109
+ text: null,
110
+ turnSeq: null,
111
+ error: null,
112
+ expanded: false,
113
+ autorun: "disarmed"
114
+ };
115
+ if (ev.status === "analyzing") return {
116
+ ...state,
117
+ status: "analyzing",
118
+ text: null,
119
+ error: null
120
+ };
121
+ return {
122
+ ...state,
123
+ status: ev.status,
124
+ text: ev.status === "ready" ? ev.text ?? null : null,
125
+ intent: ev.status === "ready" ? ev.intent ?? "worker-step" : "worker-step",
126
+ turnSeq: ev.turnSeq ?? state.turnSeq,
127
+ error: ev.status === "failed" ? ev.error ?? "the suggestion call failed" : null
128
+ };
129
+ case "expand": return {
130
+ ...state,
131
+ expanded: true
132
+ };
133
+ case "collapse": return {
134
+ ...state,
135
+ expanded: false
136
+ };
137
+ case "autorun": return {
138
+ ...state,
139
+ autorun: ev.on ? "armed" : "disarmed"
140
+ };
141
+ case "running": return {
142
+ ...state,
143
+ running: ev.running
144
+ };
145
+ case "human/prompt": return {
146
+ ...state,
147
+ autorun: "disarmed",
148
+ hiddenTurnSeq: state.turnSeq,
149
+ expanded: false
150
+ };
151
+ case "off": return {
152
+ ...state,
153
+ status: "off",
154
+ text: null,
155
+ turnSeq: null,
156
+ error: null,
157
+ expanded: false,
158
+ autorun: "disarmed"
159
+ };
160
+ case "goal": return {
161
+ ...state,
162
+ goalPhase: ev.phase,
163
+ autorun: ev.phase === "complete" || ev.phase === "blocked" ? "disarmed" : state.autorun
164
+ };
165
+ }
166
+ return state;
167
+ }
168
+ /** The status label. `failed` keeps the ready label so the error span is the delta, not a mystery row. */
169
+ function barLabel(s) {
170
+ if (s.status === "analyzing") return "Analyzing situation…";
171
+ if (s.status === "ready" || s.status === "failed") return "Recommended Next Step";
172
+ return null;
173
+ }
174
+ /**
175
+ * #193: the pull cadence, as a decision instead of an interval constant. Every pull is two recorded
176
+ * host lifecycle events (`command/run` + `command/done`) the UI renders as history nodes — cadence
177
+ * IS the litter, so it belongs in this module with a row for every arm. Active = the state can
178
+ * plausibly change on a short clock (generation in flight, an armed loop to fire, a turn running
179
+ * that will end into an offer). Quiet = stable; one pull a minute is plenty for a state that only
180
+ * changes at turn boundaries. Hidden-and-quiet = pull NOTHING (a background tab should write zero
181
+ * history); hidden-and-active still pulls, because walking away from a running loop is the point of
182
+ * autorun and pausing it silently would be the loop dying with nobody told.
183
+ */
184
+ const ACTIVE_POLL_MS = 1e4;
185
+ /**
186
+ * #229: the quiet heartbeat is CONFIG-GATED (`promptQuietHeartbeatSec`) and defaults to ZERO.
187
+ * Measured on the maintainer's live log after 0.2.11 installed correctly (gaps exactly 60.0s): every
188
+ * pull is two durable nodes — a host `command/run` + `command/done` pair — and the user's rule is
189
+ * absolute: never repeated chapters-prompt executions. The sanctioned moments (mount discovery once
190
+ * per page load, the turn-end pulse, visibility return with an untrusted cache, forced confirms)
191
+ * remain; the ambient drumbeat does not exist unless configured. This module is client-safe (no node
192
+ * builtins), so the wiring injects the config value at boot via `configureQuietHeartbeat`.
193
+ */
194
+ let QUIET_POLL_MS = 0;
195
+ function configureQuietHeartbeat(seconds) {
196
+ QUIET_POLL_MS = typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0 ? Math.trunc(seconds * 1e3) : 0;
197
+ }
198
+ function pollSpacingFor(s) {
199
+ if (s.turnEndPulse === true) return ACTIVE_POLL_MS;
200
+ if (s.edges) {
201
+ if (s.status === "analyzing" || s.autorun === "armed") return ACTIVE_POLL_MS;
202
+ return null;
203
+ }
204
+ const active = s.status === "analyzing" || s.autorun === "armed";
205
+ if (!active && s.hidden) return null;
206
+ if (!active) return QUIET_POLL_MS > 0 ? QUIET_POLL_MS : null;
207
+ return ACTIVE_POLL_MS;
208
+ }
209
+ /**
210
+ * #203: whether a freshly mounted bar pulls, or renders its cache and stays silent. The maintainer's
211
+ * rule: generate or read AFTER THE TURN ENDS, never when you activate a session. So a mount pulls
212
+ * only when the cache cannot be trusted — absent (first mount after a page load), stale (a turn
213
+ * could have ended since; this guarded pull is what keeps Run honest, because clicking a stale
214
+ * offer would submit the row's newer text while showing the old), mid-generation, or armed. A
215
+ * cached `off` never pulls: off is a human/config switch, and only an explicit `on` revisits it.
216
+ */
217
+ function mountPullDecision(c) {
218
+ if (!c.hasCache) return true;
219
+ if (c.status === "off") return false;
220
+ if (c.autorun === "armed" || c.status === "analyzing") return true;
221
+ return QUIET_POLL_MS > 0 && c.ageMs > QUIET_POLL_MS;
222
+ }
223
+ /**
224
+ * #203: fold a cached wire event back into row state through the SAME reducer the live path uses —
225
+ * one fold, no second implementation to drift (the #59 rule).
226
+ */
227
+ function stateFromCache(ev, autorun) {
228
+ const folded = promptBarReducer(initialBarState(), ev);
229
+ return autorun === null ? folded : promptBarReducer(folded, {
230
+ t: "autorun",
231
+ on: autorun === "armed"
232
+ });
233
+ }
234
+ function barVisible(s) {
235
+ if (s.status === "off") return false;
236
+ if (s.status === "ready" && (s.text === null || s.text === "")) return false;
237
+ if (s.hiddenTurnSeq !== null && s.hiddenTurnSeq === s.turnSeq) return false;
238
+ return true;
239
+ }
240
+ /** May the bar submit its own offer? Armed, present, idle, not dismissed, and still serving a purpose. */
241
+ function shouldAutoFire(s) {
242
+ if (s.status !== "ready") return false;
243
+ if (s.text === null || s.text === "") return false;
244
+ if (s.intent === "goal-edit") return false;
245
+ if (s.goal !== null && s.goal.phase === "active" && s.goal.activation === "armed") return false;
246
+ if (s.autorun !== "armed") return false;
247
+ if (s.running) return false;
248
+ if (s.goalPhase === "complete" || s.goalPhase === "blocked") return false;
249
+ if (s.hiddenTurnSeq !== null && s.hiddenTurnSeq === s.turnSeq) return false;
250
+ return true;
251
+ }
252
+ /** The command the bar pulls. One string, so the client and the command plane cannot drift silently. */
253
+ const BAR_STATE_COMMAND = "/chapters-prompt state";
254
+ /** The bar's Run button: the server submits the on-screen offer as the human's own turn. */
255
+ const BAR_RUN_COMMAND = "/chapters-prompt run";
256
+ /**
257
+ * Parse the `state` verb's `k: v` lines into a pull event.
258
+ *
259
+ * One output serving the human and the component is the design (see `docs/contract.md`); this function is
260
+ * the cost of that choice, and it is kept honest: an unrecognised line is IGNORED, not guessed at, and a
261
+ * payload that yields no `status` is `null` so the caller keeps its last state rather than inventing `off`
262
+ * (which would hide a bar that is merely mid-reply). Indentation and the prompt's own lines are the reason
263
+ * the prompt is collected separately from the scalar fields — it is the only multi-line value.
264
+ */
265
+ function parseBarState(text) {
266
+ const lines = text.split("\n");
267
+ const head = lines[0] ?? "";
268
+ const statusWord = head.startsWith("suggested next step:") ? head.slice(20).trim() : "";
269
+ const status = statusWord === "off" ? "off" : statusWord === "idle" ? "idle" : statusWord === "analyzing" ? "analyzing" : statusWord === "ready" ? "ready" : statusWord === "failed" ? "failed" : null;
270
+ if (status === null) return null;
271
+ const scalars = {};
272
+ const prompt = [];
273
+ let inPrompt = false;
274
+ for (const line of lines.slice(1)) {
275
+ if (inPrompt) {
276
+ prompt.push(line.replace(/^ {4}/, ""));
277
+ continue;
278
+ }
279
+ const m = /^ {2}([a-z]+): ?(.*)$/.exec(line);
280
+ if (m === null) continue;
281
+ const key = m[1], value = m[2].trim();
282
+ if (key === "prompt") {
283
+ inPrompt = true;
284
+ continue;
285
+ }
286
+ scalars[key] !== void 0 ? scalars[key] += ` ${value}` : scalars[key] = value;
287
+ }
288
+ configureQuietHeartbeat(Number.parseInt((scalars["heartbeat"] ?? "").trim(), 10) || 0);
289
+ const turnRaw = scalars["turn"] ?? "";
290
+ const turnSeq = /^\d+$/.test(turnRaw) ? Number(turnRaw) : null;
291
+ const errorRaw = scalars["error"] ?? "";
292
+ return {
293
+ t: "polled",
294
+ intent: ((scalars["intent"] ?? "").split(" ")[0] ?? "") === "goal-edit" ? "goal-edit" : "worker-step",
295
+ status,
296
+ ...status === "ready" ? {
297
+ text: prompt.join("\n"),
298
+ turnSeq
299
+ } : {},
300
+ ...status === "failed" ? {
301
+ error: errorRaw,
302
+ turnSeq
303
+ } : {}
304
+ };
305
+ }
306
+ /** The autorun flag, read from the same payload — the row is durable, the flag is not. */
307
+ function parseBarAutorun(text) {
308
+ const m = /^ {2}autorun: (armed|disarmed)\b/m.exec(text);
309
+ return m === null ? null : m[1];
310
+ }
311
+ /**
312
+ * Does the host say this session is busy right now?
313
+ *
314
+ * The dock's props are HOOKS (`useSessionStatus`, `useChat`, `useInput`, …) — documented in
315
+ * docs/contract.md § "What a dock component is handed" — so a component that waits for a scalar
316
+ * `props.running` waits forever, which is precisely why Autorun declared itself unavailable while the host
317
+ * was publishing the signal one field away. Shapes differ across hosts and versions, so every form is folded
318
+ * here, in one pure function, with `undefined` (unknown) kept distinct from `false` (idle): the loop refuses
319
+ * to start a turn on an unknown, and that distinction is the whole safety property.
320
+ */
321
+ function deriveRunning(status) {
322
+ if (status === void 0 || status === null) return void 0;
323
+ if (typeof status === "boolean") return status;
324
+ if (typeof status === "string") {
325
+ if (/^(running|streaming|busy|active)$/i.test(status)) return true;
326
+ if (/^(idle|done|stopped|finished|ready|waiting)$/i.test(status)) return false;
327
+ return;
328
+ }
329
+ if (typeof status === "object") {
330
+ const s = status;
331
+ if (s.running !== void 0) return typeof s.running === "boolean" ? s.running : void 0;
332
+ return deriveRunning(s.status ?? s.state ?? s.phase);
333
+ }
334
+ }
335
+ /**
336
+ * #208: the plane-wide relevance fact, read from the `state` block's own model line. `model: unset`
337
+ * means the feature does not exist on this plane — one discovery reply carrying it silences EVERY
338
+ * dock on the page (the pull transport cannot do better than one discovery; the push transport
339
+ * (#194) removes even that). An unrecognised block is false, never a guess.
340
+ */
341
+ function parseModelAbsent(text) {
342
+ return /^\s*model:\s*unset\b/m.test(text);
343
+ }
344
+ /**
345
+ * A pushed payload becomes the SAME events the pull path dispatches — one fold, one reducer, no
346
+ * second implementation to drift (#59). Null when the payload is not ours or not trustworthy: an
347
+ * unrecognised push is ignored, never guessed at, and the pull schedule underneath keeps running
348
+ * until a payload this function accepts actually arrives.
349
+ */
350
+ function pushEventsOf(payload) {
351
+ if (payload === null || payload === void 0 || typeof payload !== "object") return null;
352
+ const p = payload;
353
+ if (p.v !== 1 || typeof p.status !== "string") return null;
354
+ const status = p.status;
355
+ if (status !== "off" && status !== "idle" && status !== "analyzing" && status !== "ready" && status !== "failed") return null;
356
+ return {
357
+ polled: {
358
+ t: "polled",
359
+ status,
360
+ ...status === "ready" ? {
361
+ text: typeof p.text === "string" ? p.text : "",
362
+ turnSeq: p.turnSeq ?? null,
363
+ intent: p.intent ?? "worker-step"
364
+ } : {},
365
+ ...status === "failed" ? { error: p.error ?? "the suggestion call failed" } : {}
366
+ },
367
+ autorun: {
368
+ t: "autorun",
369
+ on: p.autorun === "armed"
370
+ }
371
+ };
372
+ }
373
+ /**
374
+ * The running signal's resolution order (scalar prop → `useSession` snapshot via `deriveRunning` →
375
+ * unknown) lives in the dock as `props.running ?? deriveRunning(sessionSnapshot)` — ONE expression,
376
+ * pinned by a source-scan row, folded by the deriveRunning rows that already cover every shape.
377
+ * A second pure helper for the same decision would be exactly the drift #59 forbids; the draft of
378
+ * #194 shipped one and this comment is its headstone.
379
+ */
380
+ /** The staleness-confirm's one question: did a push arrive after the edge that scheduled it? */
381
+ function pushIsFresher(lastPushAt, edgeAt) {
382
+ return lastPushAt !== null && lastPushAt > edgeAt;
383
+ }
384
+ /**
385
+ * #218: a DEAD-PUSH HEARTBEAT, not a staleness confirm — 20s fired mid-turn before the turn's
386
+ * record could exist (turns outlast 20s), which was confirm spam wearing a safety hat. 5 minutes:
387
+ * during normal work a record arrives every turn and the push effect retires the pending timer, so
388
+ * a healthy push NEVER fires it; only a push silent for a whole 5 minutes pulls once — a frozen
389
+ * bar is the failure this bounds, and it is worth one node every 5 minutes, not one every 20
390
+ * seconds.
391
+ * within moments (the runner writes it BEFORE calling the model). If no push arrived this long
392
+ * after the edge, the push is presumed dead and ONE confirm pull runs — a silently dead push
393
+ * degrades to one pull per turn, never to a frozen bar.
394
+ */
395
+ const PUSH_CONFIRM_GRACE_MS = 3e5;
396
+ /**
397
+ * #211 — the edge kick's delay, measured on the maintainer's host the hard way: some hosts publish
398
+ * `running` at STEP granularity (it flickers at every tool call and thinking block), and an
399
+ * immediate kick per falling edge was a rendered `chapters-prompt` node per tool call — all waste,
400
+ * because the server row changes ONLY at turn boundaries. So: a falling edge schedules its pull
401
+ * after the debounce, a rising edge cancels it (step flicker coalesces to zero pulls), and the rate
402
+ * floor bounds even a host that holds `running` low through long tool calls. A real turn end — a
403
+ * falling edge with no rising edge after it — pulls exactly once.
404
+ */
405
+ const EDGE_DEBOUNCE_MS = 2e3;
406
+ const EDGE_FLOOR_MS = 15e3;
407
+ function edgeKickDelayMs(msSinceLastPull) {
408
+ return Math.max(EDGE_DEBOUNCE_MS, EDGE_FLOOR_MS - msSinceLastPull);
409
+ }
410
+ /**
411
+ * #221: the turn-end pulse, derived from the host's own turn-boundary projection — no plugin
412
+ * record API needed, so it works on 0.2.0 hosts (where appendPluginRecord is absent) exactly as
413
+ * on newer ones. The host maintains a `turnBoundary` projection (the agent loop reads its
414
+ * lastTurn for its own bookkeeping); reading what the host already folds costs zero writes and
415
+ * delivers reactively through the same standard useProjection seat as any other projection.
416
+ * Returns true when a NEW turn ended since the last observation; the dock calls it once per
417
+ * render and stores the returned stamp back into `seen`.
418
+ */
419
+ function turnEndPulse(lastTurn, seen) {
420
+ const cur = typeof lastTurn === "number" && Number.isFinite(lastTurn) ? lastTurn : 0;
421
+ if (cur > seen) return {
422
+ pulse: true,
423
+ seen: cur
424
+ };
425
+ return {
426
+ pulse: false,
427
+ seen
428
+ };
429
+ }
430
+ // src/client/prompt-dock.ts
431
+ /**
432
+ * The Suggested Next Step dock row (#113) — thin by design.
433
+ *
434
+ * Every decision lives in `prompt-bar.ts` where a row can pin it; this file only moves pixels and bytes
435
+ * between the host slot and that reducer. That split is the reason it exists: the ghost was rejected for
436
+ * behaviour that had no home, and "one turn, one offer" cannot be tested in a component.
437
+ *
438
+ * Placement is measured, not styled: `conversation.input.dock` is the host's full-width stack rendered
439
+ * ABOVE the composer card (todo `order: 0`, goal `10`, queue `20`), so `order: 30` puts this row under every
440
+ * other dock entry and directly above the input — and because it is a docked row rather than an overlay, it
441
+ * can neither cover the placeholder nor collide with the send buttons. The ghost's ugliest defect was
442
+ * structural, and the structure fixes it.
443
+ *
444
+ * The bar is PUSH-SHAPED, PULL-FED. A registrant can own a reactive observable (`hooks: { x }` → prop
445
+ * `useX`) — that is how the goal bar gets its `activation` — but a plugin cannot publish a projection, and
446
+ * the only plugin-reachable call path is `remote.commands.execute`. So the component pulls the `state` verb
447
+ * on edges it can actually observe. Stated here because the alternative is a reader assuming a subscription
448
+ * that does not exist.
449
+ *
450
+ * @module dsh-chapters/src/client/prompt-dock
451
+ */
452
+ const CSS$1 = [
453
+ ".dsh-chapters_promptDock{box-sizing:border-box;width:calc(100% - 2 * var(--dsh-composer-side-clearance) - 4 * var(--dsh-composer-dock-inset));max-width:calc(var(--dsh-composer-card-max-width) - 4 * var(--dsh-composer-dock-inset));margin:0 auto}",
454
+ ".dsh-chapters_promptBar{isolation:isolate;box-sizing:border-box;width:100%;border-radius:var(--dsw-radius-md);height:36px;box-shadow:var(--dsw-elevation-panel);border:0;align-items:center;gap:10px;padding:4px 5px 4px 12px;display:flex;position:relative}",
455
+ ".dsh-chapters_promptBar::before{z-index:-1;border-radius:inherit;background:var(--dsw-specific-menu);backdrop-filter:var(--dsw-menu-backdrop-filter);content:\"\";pointer-events:none;position:absolute;inset:0}",
456
+ ".dsh-chapters_promptExpanded::before{align-items:flex-start;position:absolute;inset:0}",
457
+ ".dsh-chapters_promptExpanded{height:auto;min-height:36px;padding-top:8px;padding-bottom:8px}",
458
+ ".dsh-chapters_promptGlyph{color:var(--dsw-alias-label-tertiary);flex:none;display:inline-flex}",
459
+ ".dsh-chapters_promptLabel{color:var(--dsw-alias-label-primary);flex:none;font-size:13px;font-weight:500;line-height:24px}",
460
+ ".dsh-chapters_promptText{min-width:0;color:var(--dsw-alias-label-primary-dimmed);font-size:13px;line-height:20px;cursor:pointer;border:0;background:0 0;padding:0;text-align:left;flex:1 1 100%}",
461
+ ".dsh-chapters_promptEllipsis{white-space:nowrap;text-overflow:ellipsis;overflow:hidden;display:block}",
462
+ ".dsh-chapters_promptFull{white-space:pre-wrap;overflow-wrap:break-word;word-break:normal;line-height:20px;font-size:13px;color:var(--dsw-alias-label-primary);display:block;max-height:9rem;overflow-y:auto}",
463
+ ".dsh-chapters_promptError{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:20px;flex:none;max-width:34%}",
464
+ ".dsh-chapters_promptNote{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:20px;min-width:0}",
465
+ ".dsh-chapters_promptIntent{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:20px}",
466
+ ".dsh-chapters_promptActions{flex:none;align-items:center;gap:10px;display:flex}",
467
+ ".dsh-chapters_promptBtn{corner-shape:round;width:28px;height:28px;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:none;border-radius:999px;justify-content:center;align-items:center;padding:0;display:inline-flex;font-size:13px}",
468
+ ".dsh-chapters_promptBtn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}",
469
+ ".dsh-chapters_promptBtn:disabled{opacity:.4;cursor:default}"
470
+ ].join("\n");
471
+ const CSS_ID = "dsh-chapters/prompt-dock.css";
472
+ const ensureCss = () => {
473
+ if (typeof document === "undefined") return;
474
+ if (document.getElementById(CSS_ID) !== null) return;
475
+ const style = document.createElement("style");
476
+ style.id = CSS_ID;
477
+ style.setAttribute("data-plugin-css", CSS_ID);
478
+ style.textContent = CSS$1;
479
+ document.head.appendChild(style);
480
+ };
481
+ /** The analysis glyph, drawn to the goal icon's metric (16 viewBox, stroke currentColor, width 1).
482
+ * A prism-and-arrow: the feature reads the situation and points at the next move. */
483
+ const Glyph = () => (0, react.createElement)("svg", {
484
+ viewBox: "0 0 16 16",
485
+ width: 14,
486
+ height: 14,
487
+ fill: "none",
488
+ "aria-hidden": "true",
489
+ "stroke-width": "1"
490
+ }, (0, react.createElement)("path", {
491
+ d: "M2.5 3.5h11L8 9.5 2.5 3.5Z",
492
+ stroke: "currentColor"
493
+ }), (0, react.createElement)("path", {
494
+ d: "M8 9.5V14",
495
+ stroke: "currentColor"
496
+ }), (0, react.createElement)("path", {
497
+ d: "M6 12l2 2 2-2",
498
+ stroke: "currentColor"
499
+ }));
500
+ /** One action button, labelled through the host tooltip exactly as the goal bar labels its own. */
501
+ const Action = (props) => {
502
+ const button = (0, react.createElement)("button", {
503
+ type: "button",
504
+ className: "dsh-chapters_promptBtn",
505
+ "aria-label": props.label,
506
+ ...props.disabled === true ? { disabled: true } : {},
507
+ ...props.title !== void 0 ? { title: props.title } : nativeTitle(props.label),
508
+ onClick: props.onClick
509
+ }, props.glyph);
510
+ return withTooltip(props.label, button);
511
+ };
512
+ /** Run is allowed whenever there is an offer; AUTORUN is what the intent and the goal gate. Keeping the
513
+ * human's own button enabled is deliberate — the point of the protocol is to route a goal edit to a keystroke,
514
+ * never to disable the human's ability to send it. */
515
+ /**
516
+ * The words the collapsed row shows, per state. `idle` and `analyzing` must say something, because an empty
517
+ * row is indistinguishable from a broken one — the whole reason this feature took a day to read: the bar was
518
+ * live, enabled, and rendering nothing but whitespace.
519
+ */
520
+ function lineFor(state) {
521
+ if (state.status === "ready") return state.text ?? "";
522
+ if (state.status === "analyzing") return "Analyzing the session…";
523
+ if (state.status === "failed") return state.error !== null && state.error !== "" ? `Cannot recommend: ${state.error}` : "Cannot recommend a next step right now";
524
+ return "No suggestion yet — I will recommend one after your next turn";
525
+ }
526
+ const copyToClipboard = (text) => {
527
+ try {
528
+ navigator.clipboard?.writeText(text);
529
+ return true;
530
+ } catch {
531
+ return false;
532
+ }
533
+ };
534
+ const canRunHere = (state) => state.text !== null && state.text !== "";
535
+ /** How long a state pull may take before we call it failed. Generous on purpose: this is a local RPC that
536
+ * answers in milliseconds when the session is attachable, and the failure it guards against is a hang. */
537
+ const PULL_TIMEOUT_MS = 15e3;
538
+ const stateCache = /* @__PURE__ */ new Map();
539
+ /**
540
+ * #251: the memory cache dies with the page, and the maintainer's rule is absolute — a reload must
541
+ * cost ZERO chapters-prompt commands. Two page loads producing two visible `state` nodes still reads
542
+ * as "the same shit" from the chair, sanctioned or not. So the last-known truth persists per session
543
+ * in localStorage (the host's own convention for composer state — `dsh.sessions.current`, persisted
544
+ * drafts), and activation reads memory first, then storage. A cold tab renders the last offer with
545
+ * no command at all; turn boundaries refresh it through the pulse/confirm paths that already exist.
546
+ */
547
+ const PERSIST_PREFIX = "dsh.chapters.promptState.";
548
+ const PERSIST_MAX = 50;
549
+ const persistKey = (sessionId) => `${PERSIST_PREFIX}${sessionId}`;
550
+ function readPersisted(sessionId) {
551
+ try {
552
+ const raw = window.localStorage.getItem(persistKey(sessionId));
553
+ if (raw === null) return null;
554
+ const p = JSON.parse(raw);
555
+ if (typeof p.at !== "number" || p.ev === null || typeof p.ev !== "object") return null;
556
+ if (p.ev.t !== "polled") return null;
557
+ const autorun = p.autorun === "armed" || p.autorun === "disarmed" ? p.autorun : null;
558
+ return {
559
+ ev: p.ev,
560
+ autorun,
561
+ at: p.at
562
+ };
563
+ } catch {
564
+ return null;
565
+ }
566
+ }
567
+ function writePersisted(sessionId, entry) {
568
+ try {
569
+ window.localStorage.setItem(persistKey(sessionId), JSON.stringify(entry));
570
+ const keys = [];
571
+ for (let i = 0; i < window.localStorage.length; i++) {
572
+ const k = window.localStorage.key(i);
573
+ if (k !== null && k.startsWith(PERSIST_PREFIX)) keys.push(k);
574
+ }
575
+ if (keys.length <= PERSIST_MAX) return;
576
+ const aged = keys.map((k) => {
577
+ let at = 0;
578
+ try {
579
+ at = Number(JSON.parse(window.localStorage.getItem(k) ?? "{}").at ?? 0);
580
+ } catch {
581
+ at = 0;
582
+ }
583
+ return {
584
+ k,
585
+ at
586
+ };
587
+ }).sort((x, y) => x.at - y.at);
588
+ for (const old of aged.slice(0, keys.length - PERSIST_MAX)) window.localStorage.removeItem(old.k);
589
+ } catch {}
590
+ }
591
+ /** Memory first, then disk — one reader so every decision sees the same truth (#59). */
592
+ function cachedState(sessionId) {
593
+ if (sessionId === void 0) return void 0;
594
+ const mem = stateCache.get(sessionId);
595
+ if (mem !== void 0) return mem;
596
+ const disk = readPersisted(sessionId);
597
+ if (disk !== null) {
598
+ stateCache.set(sessionId, disk);
599
+ return disk;
600
+ }
601
+ }
602
+ /**
603
+ * #208: set by the first reply reporting the plane-wide `model: unset` — the feature does not
604
+ * exist here, so NO dock on this page pulls again for anything: not mounts, not idle edges, not
605
+ * timers, not visibility. One discovery per page load is the pull transport's floor; a page reload
606
+ * is the only revival, and the enabling commands say so.
607
+ */
608
+ let featureAbsent = false;
609
+ /** Retry ceiling for the FIRST pull only, in attempts and spacing. A mount-time race must not be able to
610
+ * starve the bar forever, and a real `off` answer stops the loop on the first reply either way. */
611
+ function execWithTimeout(exec, line, ms) {
612
+ return new Promise((resolve, reject) => {
613
+ const timer = setTimeout(() => {
614
+ reject(/* @__PURE__ */ new Error(`no answer within ${String(ms)}ms`));
615
+ }, ms);
616
+ exec(line).then((text) => {
617
+ clearTimeout(timer);
618
+ resolve(text);
619
+ }, (error) => {
620
+ clearTimeout(timer);
621
+ reject(error instanceof Error ? error : new Error(String(error)));
622
+ });
623
+ });
624
+ }
625
+ function PromptDock(props) {
626
+ const { sessionId, inputActions, exec } = props;
627
+ const sessionSnapshot = (props.useSession ?? (() => void 0))((snapshot) => snapshot);
628
+ const running = props.running ?? deriveRunning(sessionSnapshot);
629
+ const useProjectionHook = props.useProjection ?? (() => void 0);
630
+ const pushed = useProjectionHook(PROMPT_PROJECTION_KEY);
631
+ const turnBoundary = useProjectionHook("turnBoundary");
632
+ const [state, dispatch] = (0, react.useReducer)(promptBarReducer, initialBarState(), (s) => {
633
+ const pushEvs = pushEventsOf(pushed);
634
+ if (pushEvs !== null) return {
635
+ ...promptBarReducer(promptBarReducer(s, pushEvs.polled), pushEvs.autorun),
636
+ running: running ?? true
637
+ };
638
+ const c = cachedState(sessionId);
639
+ return {
640
+ ...c !== void 0 ? stateFromCache(c.ev, c.autorun) : s,
641
+ running: running ?? true
642
+ };
643
+ });
644
+ const pulling = (0, react.useRef)(false);
645
+ const probed = (0, react.useRef)(false);
646
+ const pulled = (0, react.useRef)(false);
647
+ const alive = (0, react.useRef)(true);
648
+ (0, react.useEffect)(() => () => {
649
+ alive.current = false;
650
+ }, []);
651
+ const [busy, setBusy] = (0, react.useState)(false);
652
+ const kick = (0, react.useRef)(void 0);
653
+ const lastPullAt = (0, react.useRef)(null);
654
+ const edgeTimer = (0, react.useRef)(void 0);
655
+ const pushLiveRef = (0, react.useRef)(false);
656
+ const seenTurnRef = (0, react.useRef)(0);
657
+ const lastPushAt = (0, react.useRef)(null);
658
+ const answered = (0, react.useRef)(false);
659
+ const replyStatus = (0, react.useRef)("unknown");
660
+ const stateRef = (0, react.useRef)(initialBarState());
661
+ stateRef.current = state;
662
+ const edgesRef = (0, react.useRef)(props.running !== void 0);
663
+ edgesRef.current = props.running !== void 0;
664
+ if (!probed.current) {
665
+ probed.current = true;
666
+ console.info(`[client:prompt] inputActions keys: ${Object.keys(inputActions ?? {}).join(",") || "NONE"}`);
667
+ }
668
+ const refresh = (0, react.useCallback)(async () => {
669
+ if (exec === void 0 || pulling.current) return;
670
+ pulling.current = true;
671
+ lastPullAt.current = Date.now();
672
+ try {
673
+ const text = await execWithTimeout(exec, BAR_STATE_COMMAND, PULL_TIMEOUT_MS);
674
+ try {
675
+ window.__dshPromptReply = text;
676
+ } catch {}
677
+ const ev = parseBarState(text);
678
+ if (ev !== null) {
679
+ answered.current = true;
680
+ replyStatus.current = ev.status ?? replyStatus.current;
681
+ dispatch(ev);
682
+ }
683
+ const autorun = parseBarAutorun(text);
684
+ if (autorun !== null) dispatch({
685
+ t: "autorun",
686
+ on: autorun === "armed"
687
+ });
688
+ if (parseModelAbsent(text)) featureAbsent = true;
689
+ if (ev !== null && sessionId !== void 0) {
690
+ stateCache.set(sessionId, {
691
+ ev,
692
+ autorun: autorun === "armed" || autorun === "disarmed" ? autorun : null,
693
+ at: Date.now()
694
+ });
695
+ writePersisted(sessionId, {
696
+ ev,
697
+ autorun: autorun === "armed" || autorun === "disarmed" ? autorun : null,
698
+ at: Date.now()
699
+ });
700
+ }
701
+ } catch (error) {
702
+ console.warn(`[client:prompt] pull failed: ${String(error?.message ?? error).slice(0, 180)}`);
703
+ } finally {
704
+ pulling.current = false;
705
+ }
706
+ }, [exec]);
707
+ (0, react.useEffect)(() => {
708
+ const evs = pushEventsOf(pushed);
709
+ if (evs === null) return;
710
+ pushLiveRef.current = true;
711
+ lastPushAt.current = Date.now();
712
+ dispatch(evs.polled);
713
+ dispatch(evs.autorun);
714
+ if (edgeTimer.current !== void 0) {
715
+ clearTimeout(edgeTimer.current);
716
+ edgeTimer.current = void 0;
717
+ }
718
+ }, [pushed]);
719
+ (0, react.useEffect)(() => {
720
+ ensureCss();
721
+ if (pulled.current) return;
722
+ pulled.current = true;
723
+ let timer;
724
+ const effectiveStatus = () => replyStatus.current === "unknown" ? stateRef.current.status : replyStatus.current;
725
+ const spacingNow = () => {
726
+ if (stateRef.current.autorun === "armed") return ACTIVE_POLL_MS;
727
+ const pe = turnEndPulse(turnBoundary?.lastTurn, seenTurnRef.current);
728
+ if (pe.pulse) seenTurnRef.current = pe.seen;
729
+ return pollSpacingFor({
730
+ status: effectiveStatus(),
731
+ autorun: stateRef.current.autorun,
732
+ running: stateRef.current.running,
733
+ hidden: typeof document !== "undefined" && document.hidden === true,
734
+ edges: edgesRef.current,
735
+ turnEndPulse: pe.pulse
736
+ });
737
+ };
738
+ const duePull = () => {
739
+ const c = cachedState(sessionId);
740
+ return mountPullDecision({
741
+ hasCache: c !== void 0,
742
+ ageMs: c !== void 0 ? Date.now() - c.at : Number.MAX_SAFE_INTEGER,
743
+ status: effectiveStatus(),
744
+ autorun: stateRef.current.autorun
745
+ });
746
+ };
747
+ const tick = async (opts) => {
748
+ if (pushLiveRef.current && opts?.force !== true) return;
749
+ if (featureAbsent) return;
750
+ if (effectiveStatus() === "off") {
751
+ if (!(opts?.mount === true && duePull())) return;
752
+ }
753
+ const spacing = spacingNow();
754
+ const mountDiscovery = opts?.mount === true && duePull();
755
+ if (opts?.force === true || mountDiscovery || spacing !== null && opts?.mount !== true) await refresh();
756
+ if (replyStatus.current === "off") return;
757
+ const next = spacingNow();
758
+ if (next !== null) timer = setTimeout(() => {
759
+ tick();
760
+ }, next);
761
+ };
762
+ kick.current = () => {
763
+ if (timer !== void 0) clearTimeout(timer);
764
+ tick({ force: true });
765
+ };
766
+ const onVisible = () => {
767
+ if (typeof document !== "undefined" && !document.hidden && duePull()) kick.current?.();
768
+ };
769
+ if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible);
770
+ tick({ mount: true });
771
+ return () => {
772
+ if (timer !== void 0) clearTimeout(timer);
773
+ if (edgeTimer.current !== void 0) {
774
+ clearTimeout(edgeTimer.current);
775
+ edgeTimer.current = void 0;
776
+ }
777
+ kick.current = void 0;
778
+ if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible);
779
+ };
780
+ }, [refresh]);
781
+ const prevRunning = (0, react.useRef)(running ?? true);
782
+ (0, react.useEffect)(() => {
783
+ const next = running ?? true;
784
+ const prev = prevRunning.current;
785
+ prevRunning.current = next;
786
+ dispatch({
787
+ t: "running",
788
+ running: next
789
+ });
790
+ if (prev && !next) {
791
+ if (edgeTimer.current !== void 0) clearTimeout(edgeTimer.current);
792
+ const since = lastPullAt.current === null ? Number.MAX_SAFE_INTEGER : Date.now() - lastPullAt.current;
793
+ const delay = pushLiveRef.current ? Math.max(PUSH_CONFIRM_GRACE_MS, edgeKickDelayMs(since)) : edgeKickDelayMs(since);
794
+ const edgeAt = Date.now();
795
+ edgeTimer.current = setTimeout(() => {
796
+ edgeTimer.current = void 0;
797
+ if (pushLiveRef.current && pushIsFresher(lastPushAt.current, edgeAt)) return;
798
+ kick.current?.();
799
+ }, delay);
800
+ } else if (!prev && next) {
801
+ if (edgeTimer.current !== void 0) {
802
+ clearTimeout(edgeTimer.current);
803
+ edgeTimer.current = void 0;
804
+ }
805
+ }
806
+ }, [running]);
807
+ const run = (0, react.useCallback)(() => {
808
+ if (state.text === null || state.text === "" || exec === void 0) return;
809
+ setBusy(true);
810
+ exec(BAR_RUN_COMMAND).then((out) => {
811
+ if (!alive.current) return;
812
+ if (/^cannot run:/i.test(out.trim())) dispatch({
813
+ t: "polled",
814
+ status: "failed",
815
+ text: state.text,
816
+ error: out.trim().slice(0, 200),
817
+ turnSeq: state.turnSeq
818
+ });
819
+ else dispatch({ t: "collapse" });
820
+ }).catch((error) => {
821
+ console.warn(`[client:prompt] run failed: ${String(error?.message ?? error).slice(0, 160)}`);
822
+ }).finally(() => {
823
+ if (alive.current) setBusy(false);
824
+ });
825
+ }, [
826
+ exec,
827
+ state.text,
828
+ state.turnSeq
829
+ ]);
830
+ (0, react.useEffect)(() => {
831
+ if (!shouldAutoFire(state)) return;
832
+ run();
833
+ }, [state, run]);
834
+ const autorunDisabled = running === void 0;
835
+ if (sessionId === void 0 || !barVisible(state)) return null;
836
+ const populate = () => {
837
+ console.info(`[client:prompt] populate expanded=${String(state.expanded)} hasInsertText=${String(typeof inputActions?.insertText)} hasSetDraft=${String(typeof inputActions?.setDraft)} textLen=${String(state.text?.length ?? -1)}`);
838
+ if (!state.expanded) {
839
+ dispatch({ t: "expand" });
840
+ return;
841
+ }
842
+ if (state.text === null || state.text === "") return;
843
+ if (typeof inputActions?.insertText === "function") {
844
+ try {
845
+ document.querySelector("[contenteditable=\"true\"][role=\"textbox\"]")?.focus();
846
+ } catch {}
847
+ const refuse = (why) => {
848
+ console.warn(`[client:prompt] click-to-draft refused: ${why}`);
849
+ const copied = copyToClipboard(state.text ?? "");
850
+ dispatch({
851
+ t: "note",
852
+ note: copied ? "The composer refused this write (" + why.slice(0, 60) + ") — the prompt is COPIED: paste it into the input to edit, or press Run ▶ to send as-is." : "The composer refused this write (" + why.slice(0, 60) + ") — press Run ▶ to send the offer as-is, or copy the text above."
853
+ });
854
+ };
855
+ let wrote = false;
856
+ try {
857
+ const span = typeof inputActions.captureInsertion === "function" ? inputActions.captureInsertion() : null;
858
+ if (span !== null && span !== void 0) wrote = inputActions.insertText(state.text, span) === true;
859
+ } catch {
860
+ wrote = false;
861
+ }
862
+ if (!wrote && typeof inputActions.setDraft === "function") try {
863
+ inputActions.setDraft(state.text);
864
+ wrote = true;
865
+ } catch {
866
+ wrote = false;
867
+ }
868
+ if (!wrote) {
869
+ refuse("neither insertText nor setDraft was accepted");
870
+ return;
871
+ }
872
+ const head = state.text.slice(0, 24);
873
+ setTimeout(() => {
874
+ try {
875
+ const dom = (document.querySelector("[contenteditable=\"true\"][role=\"textbox\"]")?.innerText ?? "").includes(head);
876
+ const stored = Object.entries(localStorage).some(([, v]) => String(v).includes(head));
877
+ if (!dom && !stored) refuse("the write was accepted but nothing holds the text");
878
+ } catch {}
879
+ }, 400);
880
+ return;
881
+ }
882
+ if (typeof inputActions?.setDraft === "function") {
883
+ inputActions.setDraft(state.text);
884
+ return;
885
+ }
886
+ console.warn("[client:prompt] click-to-draft refused: this host exposes neither insertText nor setDraft");
887
+ };
888
+ return (0, react.createElement)("div", {
889
+ className: "dsh-chapters_promptDock",
890
+ "data-prompt-bar": "true"
891
+ }, (0, react.createElement)("div", { className: `dsh-chapters_promptBar${state.expanded ? " dsh-chapters_promptExpanded" : ""}` }, (0, react.createElement)("span", { className: "dsh-chapters_promptGlyph" }, (0, react.createElement)(Glyph)), (0, react.createElement)("span", { className: "dsh-chapters_promptLabel" }, barLabel(state) ?? ""), ...state.intent === "goal-edit" ? [(0, react.createElement)("span", {
892
+ key: "intent",
893
+ className: "dsh-chapters_promptIntent",
894
+ "aria-label": "This step edits the goal"
895
+ }, "edits the goal · run it yourself")] : [], (0, react.createElement)("button", {
896
+ type: "button",
897
+ className: "dsh-chapters_promptText",
898
+ "aria-label": state.expanded ? "Put this suggested prompt in the input box" : "Show the full suggested prompt",
899
+ onClick: populate
900
+ }, state.expanded ? (0, react.createElement)("span", { className: "dsh-chapters_promptFull" }, state.text ?? "") : (0, react.createElement)("span", {
901
+ className: "dsh-chapters_promptEllipsis",
902
+ title: state.text ?? void 0
903
+ }, lineFor(state))), ...state.note === "" ? [] : [(0, react.createElement)("span", {
904
+ key: "note",
905
+ className: "dsh-chapters_promptNote",
906
+ role: "status"
907
+ }, state.note)], ...state.error === "" ? [] : [(0, react.createElement)("span", {
908
+ key: "err",
909
+ className: "dsh-chapters_promptError",
910
+ role: "alert"
911
+ }, state.error)], (0, react.createElement)("div", { className: "dsh-chapters_promptActions" }, (0, react.createElement)(Action, {
912
+ label: "Run the suggested next step",
913
+ glyph: "▶",
914
+ onClick: run,
915
+ disabled: busy,
916
+ ...state.text === null || state.text === "" || !canRunHere(state) ? { disabled: true } : {},
917
+ ...state.intent === "goal-edit" ? { title: "Goal edits need a human turn. Run still works; autorun will never do this one." } : {}
918
+ }), (0, react.createElement)(Action, {
919
+ label: state.autorun === "armed" ? "Autorun off" : "Autorun",
920
+ glyph: state.autorun === "armed" ? "⟳" : "⟲",
921
+ disabled: autorunDisabled,
922
+ ...autorunDisabled ? { title: "Autorun needs the host to publish an idle signal; this build does not, so nothing would fire safely." } : {},
923
+ onClick: () => {
924
+ (async () => {
925
+ const arm = state.autorun !== "armed";
926
+ if (exec !== void 0) await exec(`/chapters-prompt autorun ${arm ? "on" : "off"}`);
927
+ dispatch({
928
+ t: "autorun",
929
+ on: arm
930
+ });
931
+ if (!pushLiveRef.current) await refresh();
932
+ })();
933
+ }
934
+ }), (0, react.createElement)(Action, {
935
+ label: "Turn prompt suggestions off for this session",
936
+ glyph: "✕",
937
+ onClick: () => {
938
+ (async () => {
939
+ if (exec !== void 0) await exec("/chapters-prompt off");
940
+ dispatch({ t: "off" });
941
+ if (sessionId !== void 0) {
942
+ const offEntry = {
943
+ ev: {
944
+ t: "polled",
945
+ status: "off"
946
+ },
947
+ autorun: null,
948
+ at: Date.now()
949
+ };
950
+ stateCache.set(sessionId, offEntry);
951
+ writePersisted(sessionId, offEntry);
952
+ }
953
+ })();
954
+ }
955
+ }))));
956
+ }
957
+ // src/client/doors.ts
958
+ const DOOR_ACTIONS = [{
959
+ id: "chapters-fork",
960
+ order: 30,
961
+ label: "Fork with chapters",
962
+ command: "/chapters-fork",
963
+ noun: "branch",
964
+ iconPath: "M5 3.25a2.25 2.25 0 1 0-1.5 2.12v5.26a2.25 2.25 0 1 0 1.5 0V9h5a2.25 2.25 0 0 0 2.25-2.25v-1.4a2.25 2.25 0 1 0-1.5 0v1.4A.75.75 0 0 1 10 7.5H5V5.37A2.25 2.25 0 0 0 5 3.25Z"
965
+ }, {
966
+ id: "chapters-continue",
967
+ order: 31,
968
+ label: "Continue with chapters",
969
+ command: "/chapters-continue",
970
+ noun: "continuation",
971
+ iconPath: "M1 8a.75.75 0 0 1 0-1.5h9.44L7.72 3.28a.75.75 0 1 1 1.06-1.06l3.5 3.5.53.53-.53.53-3.5 3.5a.75.75 0 1 1-1.06-1.06L10.44 9.5H1Zm13-5.25v10.5a.75.75 0 0 1-1.5 0v-10.5a.75.75 0 0 1 1.5 0Z"
972
+ }];
973
+ /** Pull the child id out of either door's durable success text — both end in
974
+ * `… is session ch-…` (src/tools.ts), so this regex is door-agnostic. */
975
+ /**
976
+ * Which build of THIS file produced a door note (#77). When the live probe reported a roster
977
+ * trace of `missing → missing → missing` — meaning adoption should have fired three times — while
978
+ * showing no adoption outcome at all, the first question stopped being "why did the client refuse"
979
+ * and became "did the browser run the code I am reading?". A note that names its own build makes
980
+ * that a glance instead of an archaeology session into symlinked plugin trees.
981
+ */
982
+ const DOOR_BUILD = "2026-10-22T23:55 door-build-stamp";
983
+ const CHILD_ID = /is session (ch-[\w-]+)/;
984
+ /** The quoted title the door gave the child, matched by its OWN noun so a
985
+ * refusal or an unrelated quote cannot pass for it. */
986
+ const doorTitle = (noun) => new RegExp(`${noun} \\u201C([^\\u201D]+)\\u201D is session`);
987
+ /**
988
+ * A refusal reason that can never render as nothing. `String(err.message ?? err)` is `''` for a
989
+ * host error built with an empty message, and an empty reason disappears from the note — the
990
+ * reader then concludes "no refusal happened", which is the opposite of the truth (#77).
991
+ */
992
+ function refusalReason(error) {
993
+ const trimmed = (error instanceof Error ? error.message : String(error)).trim();
994
+ const named = trimmed === "" ? error instanceof Error && error.name.trim() !== "" ? error.name : "" : trimmed;
995
+ return (named === "" ? error === null ? "null thrown" : typeof error : named).replace(/\s+/g, " ").slice(0, 90);
996
+ }
997
+ /**
998
+ * Is the child in the client's OWN roster? This is the question that separates two very
999
+ * different failures, and the door used to report the symptom without it:
1000
+ * - the client never learned the session exists (roster: missing) → the fix is server-side
1001
+ * publication, and no amount of clicking `open` will ever work;
1002
+ * - the client knows it and `open` still did nothing (roster: contains) → a navigation bug.
1003
+ * Measured host 0.2.0-rc.2: the sessions service exposes `projectList`, `refresh`, `retain`,
1004
+ * `handleSessionAdded` — the roster is a projection the client fills from control frames.
1005
+ */
1006
+ /**
1007
+ * The client's own session ids, or `null` when the store cannot be read. ONE reader for both
1008
+ * the note and the adoption decision, so the door never acts on a verdict it did not report.
1009
+ */
1010
+ function rosterIds(sessions) {
1011
+ const store = sessions?.list;
1012
+ if (typeof store?.getSnapshot !== "function") return null;
1013
+ const snap = store.getSnapshot();
1014
+ const ids = /* @__PURE__ */ new Set();
1015
+ if (Array.isArray(snap?.ids)) for (const id of snap.ids) ids.add(String(id));
1016
+ else if (snap?.byId !== void 0) for (const k of Object.keys(snap.byId)) ids.add(k);
1017
+ else return null;
1018
+ return ids;
1019
+ }
1020
+ function rosterNote(sessions, child) {
1021
+ try {
1022
+ const ids = rosterIds(sessions);
1023
+ if (ids === null) return ` (roster: ${typeof (sessions?.list)?.getSnapshot !== "function" ? "no list store on the service" : "snapshot unreadable"} — presence of ${child.slice(0, 11)}… is UNKNOWN, not absent)`;
1024
+ if (ids.size === 0) return ` (roster: empty — presence of ${child.slice(0, 11)}… is UNKNOWN, not absent)`;
1025
+ const n = ids.size;
1026
+ return ids.has(child) ? ` (roster: contains ${child.slice(0, 11)}…, ${String(n)} sessions — open() is the failure)` : ` (roster: MISSING, ${String(n)} sessions — the client never catalogued this session)`;
1027
+ } catch {
1028
+ return "";
1029
+ }
1030
+ }
1031
+ /** The service's own method names, as a hint attached to a failure note (never a guess). */
1032
+ function verbsNote(sessions) {
1033
+ try {
1034
+ const svc = sessions;
1035
+ if (svc === null || svc === void 0 || typeof svc !== "object") return " (sessions service: absent)";
1036
+ const found = /* @__PURE__ */ new Set();
1037
+ let node = svc;
1038
+ for (let depth = 0; depth < 6 && node !== null && node !== void 0 && node !== Object.prototype; depth++) {
1039
+ for (const key of Object.getOwnPropertyNames(node)) {
1040
+ if (key === "constructor") continue;
1041
+ try {
1042
+ if (typeof svc[key] === "function") found.add(key);
1043
+ } catch {}
1044
+ }
1045
+ node = Object.getPrototypeOf(node);
1046
+ }
1047
+ const verbs = [...found].sort().slice(0, 24);
1048
+ return verbs.length === 0 ? " (sessions service: no methods on the chain)" : ` (sessions verbs: ${verbs.join(", ")})`;
1049
+ } catch {
1050
+ return " (sessions service: unreadable)";
1051
+ }
1052
+ }
1053
+ function openByService(sessions, sessionId, adopt, nav) {
1054
+ return (async () => {
1055
+ const svc = sessions;
1056
+ if (svc === void 0) return false;
1057
+ const navigate = typeof nav?.openSession === "function" ? (id) => nav.openSession(id) : typeof svc?.open === "function" ? (id) => svc.open(id) : null;
1058
+ if (navigate === null) {
1059
+ adopt?.onStep?.("no-verb");
1060
+ return false;
1061
+ }
1062
+ try {
1063
+ await svc.refresh?.();
1064
+ } catch {}
1065
+ try {
1066
+ svc.projectList?.();
1067
+ } catch {}
1068
+ if (adopt?.needed === true && typeof svc.create === "function") {
1069
+ adopt.onStep?.("enter-create");
1070
+ try {
1071
+ await svc.create({ sessionId });
1072
+ adopt.onStep?.("settle-create");
1073
+ try {
1074
+ svc.projectList?.();
1075
+ } catch {}
1076
+ const after = rosterIds(sessions);
1077
+ adopt.onAfter?.(after === null ? null : after.has(sessionId));
1078
+ } catch (error) {
1079
+ adopt.onStep?.("refuse-create");
1080
+ adopt.onRefused(refusalReason(error));
1081
+ }
1082
+ }
1083
+ if (adopt?.needed === true && typeof svc.using === "function" && rosterIds(sessions)?.has(sessionId) === false) {
1084
+ adopt.onStep?.("enter-using");
1085
+ try {
1086
+ await svc.using(sessionId, { source: "dsh-chapters-door" }, async () => void 0);
1087
+ adopt.onStep?.("settle-using");
1088
+ try {
1089
+ svc.projectList?.();
1090
+ } catch {}
1091
+ const seen = rosterIds(sessions);
1092
+ adopt.onAfter?.(seen === null ? null : seen.has(sessionId));
1093
+ } catch (error) {
1094
+ adopt.onStep?.("refuse-using");
1095
+ adopt.onSubscribeRefused?.(refusalReason(error));
1096
+ }
1097
+ }
1098
+ const started = Date.now();
1099
+ for (;;) try {
1100
+ navigate(sessionId);
1101
+ return true;
1102
+ } catch {
1103
+ if (Date.now() - started > 4e3) return false;
1104
+ await new Promise((r) => setTimeout(r, 250));
1105
+ }
1106
+ })();
1107
+ }
1108
+ /**
1109
+ * Fallback path: expand collapsed groups / show-more buttons, then click the
1110
+ * newest treeitem whose label contains the child title (newest-updated sorts
1111
+ * first, so the first match is the fresh child).
1112
+ */
1113
+ async function openBranchRow(title, budgetMs = 4e3) {
1114
+ if (typeof document === "undefined") return false;
1115
+ const needle = title.toLowerCase();
1116
+ const tryClick = () => {
1117
+ const hit = Array.from(document.querySelectorAll("[role=\"treeitem\"]")).find((row) => (row.textContent ?? "").toLowerCase().includes(needle));
1118
+ if (hit === void 0) return false;
1119
+ hit.click();
1120
+ return true;
1121
+ };
1122
+ const clickMore = () => {
1123
+ const buttons = Array.from(document.querySelectorAll("button")).filter((b) => /more sessions|ungrouped/i.test(b.textContent ?? "") && b.getAttribute("aria-expanded") !== "true");
1124
+ for (const b of buttons) b.click();
1125
+ return buttons.length;
1126
+ };
1127
+ const started = Date.now();
1128
+ let expansions = 0;
1129
+ while (Date.now() - started < budgetMs) {
1130
+ if (tryClick()) return true;
1131
+ if (expansions < 4 && clickMore() > 0) {
1132
+ expansions += 1;
1133
+ await new Promise((r) => setTimeout(r, 250));
1134
+ continue;
1135
+ }
1136
+ await new Promise((resolve) => setTimeout(resolve, 150));
1137
+ }
1138
+ return tryClick();
1139
+ }
1140
+ function makeDoorRunner(remote, sessions, door, verifySwitched, opts) {
1141
+ return async function run(sessionId, anchorSeq) {
1142
+ try {
1143
+ if (remote.commands?.execute === void 0) return {
1144
+ ok: false,
1145
+ message: "commands remote unavailable"
1146
+ };
1147
+ const line = typeof anchorSeq === "number" && Number.isFinite(anchorSeq) ? `${door.command} ${String(Math.trunc(anchorSeq))}` : door.command;
1148
+ const res = await remote.commands.execute(sessionId, line, []);
1149
+ if (res.ok !== true) return {
1150
+ ok: false,
1151
+ message: (res.error?.message ?? `the ${door.noun} call failed`).slice(0, 90)
1152
+ };
1153
+ const result = res.value?.result;
1154
+ const text = typeof result?.text === "string" ? result.text : "";
1155
+ if (result?.kind !== "success") return {
1156
+ ok: false,
1157
+ message: (text || `the ${door.noun} was refused — see the command result`).slice(0, 90)
1158
+ };
1159
+ const child = CHILD_ID.exec(text)?.[1];
1160
+ if (child === void 0) return {
1161
+ ok: true,
1162
+ message: `${door.noun} created — appearing in the sidebar`
1163
+ };
1164
+ const switchedOnce = async () => {
1165
+ if (verifySwitched === void 0) return true;
1166
+ try {
1167
+ return await verifySwitched(child);
1168
+ } catch {
1169
+ return false;
1170
+ }
1171
+ };
1172
+ const maxAttempts = typeof sessions?.refresh === "function" ? opts?.attempts ?? 3 : 1;
1173
+ let tried = 0;
1174
+ let adoptRefusal = "";
1175
+ let subscribeRefusal = "";
1176
+ const rosterTrace = [];
1177
+ const steps = [];
1178
+ let adoptSaw;
1179
+ for (let i = 0; i < maxAttempts; i++) {
1180
+ tried += 1;
1181
+ const ids = rosterIds(sessions);
1182
+ const needed = ids !== null && !ids.has(child);
1183
+ const svcShape = sessions;
1184
+ rosterTrace.push(`${ids === null ? "unknown" : ids.size === 0 ? "empty" : ids.has(child) ? "has" : "missing"}${typeof svcShape?.create === "function" ? "+c" : "-c"}${typeof svcShape?.using === "function" ? "+u" : "-u"}${typeof svcShape?.open === "function" ? "+o" : "-o"}${typeof opts?.navigator?.openSession === "function" ? "+w" : "-w"}`);
1185
+ if (await openByService(sessions, child, {
1186
+ needed,
1187
+ onRefused: (reason) => {
1188
+ adoptRefusal = reason;
1189
+ },
1190
+ onSubscribeRefused: (reason) => {
1191
+ subscribeRefusal = reason;
1192
+ },
1193
+ onAfter: (seen) => {
1194
+ adoptSaw = seen;
1195
+ },
1196
+ onStep: (step) => {
1197
+ if (!steps.includes(step)) steps.push(step);
1198
+ }
1199
+ }, opts?.navigator) && await switchedOnce()) return {
1200
+ ok: true,
1201
+ message: ""
1202
+ };
1203
+ if (i + 1 < maxAttempts) await new Promise((r) => {
1204
+ setTimeout(r, opts?.attemptPauseMs ?? 300).unref?.();
1205
+ });
1206
+ }
1207
+ const title = doorTitle(door.noun).exec(text)?.[1];
1208
+ if (title !== void 0 && await openBranchRow(title) && await switchedOnce()) return {
1209
+ ok: true,
1210
+ message: ""
1211
+ };
1212
+ return verifySwitched === void 0 ? {
1213
+ ok: true,
1214
+ message: `${door.noun} created — open it in the sidebar`
1215
+ } : {
1216
+ ok: true,
1217
+ message: `${door.noun} created — the app did not switch after ${String(tried)} attempt${tried === 1 ? "" : "s"}; open it from the sidebar [roster per attempt: ${rosterTrace.join(" → ")}] [tried: ${steps.length === 0 ? "none" : steps.includes("no-verb") ? "no open or openSession verb on any injected service" : steps.join(", ")}] (build: ${DOOR_BUILD})${adoptRefusal === "" ? "" : ` (adoption refused: ${adoptRefusal})`}${subscribeRefusal === "" ? "" : ` (subscribe refused: ${subscribeRefusal})`}${adoptSaw === void 0 ? "" : ` (right after adopt: ${adoptSaw ? "contains" : "missing"} ${child.slice(0, 15)})`}${rosterNote(sessions, child)}${verbsNote(sessions)}`
1218
+ };
1219
+ } catch (error) {
1220
+ return {
1221
+ ok: false,
1222
+ message: String(error?.message ?? error).slice(0, 90)
1223
+ };
1224
+ }
1225
+ };
1226
+ }
1227
+ /**
1228
+ * Execute a host command line for one session and return its text, or '' when the command plane is
1229
+ * unavailable. The bar's `state` pull and `off` write ride this, because it is the ONE place the client
1230
+ * talks to the wire: the guard row "there is no second runner body in the client chrome" exists to keep
1231
+ * `index.ts` as registration chrome, and a duplicated `commands.execute` in two files is how a wire
1232
+ * behaviour drifts from its own test (#59's one-predicate rule, applied to the transport).
1233
+ */
1234
+ async function runCommandText(remote, sessionId, line) {
1235
+ const exec = remote?.commands?.execute;
1236
+ if (exec === void 0) return "";
1237
+ const res = await exec(sessionId, line, []);
1238
+ if (res.ok !== true) return "";
1239
+ return res.value?.result?.text ?? "";
1240
+ }
1241
+ // src/client/index.ts
1242
+ /**
1243
+ * dsh-chapters client entry: the per-message door buttons.
1244
+ *
1245
+ * Registers the two deterministic archive doors into the assistant action row
1246
+ * (`conversation.chat.assistant-actions` — the slot the official feedback
1247
+ * package populates beside copy/branch). Clicking one executes its host
1248
+ * command — `/chapters-fork`, `/chapters-continue` — through `remote.commands`,
1249
+ * the exact wire the composer's input line rides, then SWITCHES the app to the
1250
+ * fresh child:
1251
+ *
1252
+ * 1. `sessions.open(child)` — the client sessions service carries open() on
1253
+ * its prototype (Playwright-verified against this host; a keys-dump alone
1254
+ * hid it, which is why an earlier round wrongly gave up on this path);
1255
+ * 2. fallback: click the child's sidebar row after expanding collapsed
1256
+ * groups (plugin children can render under collapsed 'Ungrouped' heads);
1257
+ * 3. last resort: an inline note — the child is durable either way.
1258
+ *
1259
+ * There is deliberately NO second implementation of door logic here: the
1260
+ * buttons are mouths for the host commands (watermark, segmentation, budget,
1261
+ * titles, refusal-with-numbers all server-side, all tested), and the runner
1262
+ * they share is `src/client/doors.ts` — one body, one row per door. The purity
1263
+ * gate in tsdown.client.config.ts keeps this file cross-plugin-free.
1264
+ *
1265
+ * @module dsh-chapters/src/client
1266
+ */
1267
+ const inject = [
1268
+ "slots",
1269
+ "remote",
1270
+ "remote.commands",
1271
+ "sessions",
1272
+ "uiWorkspace"
1273
+ ];
1274
+ /**
1275
+ * Style parity with the native IconActions row: rules copied verbatim from the
1276
+ * host's MessageIconActions module (`._xzv4MW_action` in dsh-client-ui-chat's
1277
+ * compiled client + the feedback package's :disabled) — same metrics, tokens,
1278
+ * hover. Update this string if the host restyles its action row. Both doors
1279
+ * share these metrics.
1280
+ *
1281
+ * The final rule hides the native branch action IN THE MESSAGE ROW only
1282
+ * (user-approved cosmetic decision, docs/architecture.md): built-in chrome, so
1283
+ * the selector is its accessible label scoped to action-row buttons; both
1284
+ * locale spellings covered. If the host rewords the label the button simply
1285
+ * reappears — fails open, visibly.
1286
+ */
1287
+ const CSS = [
1288
+ ".dsh-chapters_forkAction{width:calc(28px + var(--dsh-content-font-delta,0px));height:calc(28px + var(--dsh-content-font-delta,0px));color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:none;border-radius:28px;justify-content:center;align-items:center;padding:6px;display:inline-flex}",
1289
+ ".dsh-chapters_forkAction svg{width:calc(15px + var(--dsh-content-font-delta,0px));height:calc(15px + var(--dsh-content-font-delta,0px))}",
1290
+ ".dsh-chapters_forkAction:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}",
1291
+ ".dsh-chapters_forkAction:disabled{cursor:default;opacity:.4}",
1292
+ ".dsh-chapters_forkNote{color:var(--dsw-alias-label-tertiary);padding-left:4px;font-size:13px;line-height:20px}",
1293
+ "button[class*=\"_action\"][aria-label=\"Branch into a new conversation\"],button[class*=\"_action\"][aria-label=\"在新对话中分支\"]{display:none !important}"
1294
+ ].join("\n");
1295
+ const CSS_TAG_ID = "dsh-chapters/assistant-action.css";
1296
+ function injectStyles() {
1297
+ try {
1298
+ if (typeof document === "undefined" || document.querySelector(`style[data-plugin-css=${JSON.stringify(CSS_TAG_ID)}]`) !== null) return;
1299
+ const tag = document.createElement("style");
1300
+ tag.dataset.plugin = "dsh-chapters";
1301
+ tag.dataset.pluginCss = CSS_TAG_ID;
1302
+ tag.textContent = CSS;
1303
+ document.head.appendChild(tag);
1304
+ } catch {}
1305
+ }
1306
+ /** One component body for every door row; only the label and glyph differ. */
1307
+ function makeDoorAction(door) {
1308
+ return function ChaptersDoorAction({ run }) {
1309
+ const [busy, setBusy] = (0, react.useState)(false);
1310
+ const [note, setNote] = (0, react.useState)(null);
1311
+ const alive = (0, react.useRef)(true);
1312
+ (0, react.useEffect)(() => () => {
1313
+ alive.current = false;
1314
+ }, []);
1315
+ const onClick = (0, react.useCallback)(() => {
1316
+ if (busy) return;
1317
+ setBusy(true);
1318
+ setNote(null);
1319
+ run().then((result) => {
1320
+ if (!alive.current) return;
1321
+ setBusy(false);
1322
+ setNote(result.message === "" ? null : result.message);
1323
+ setTimeout(() => {
1324
+ if (alive.current) setNote(null);
1325
+ }, 8e3);
1326
+ });
1327
+ }, [busy, run]);
1328
+ const button = (0, react.createElement)("button", {
1329
+ type: "button",
1330
+ "aria-label": door.label,
1331
+ ...nativeTitle(door.label),
1332
+ disabled: busy,
1333
+ onClick,
1334
+ className: "dsh-chapters_forkAction"
1335
+ }, (0, react.createElement)("svg", {
1336
+ viewBox: "0 0 16 16",
1337
+ width: "15",
1338
+ height: "15",
1339
+ fill: "currentColor",
1340
+ "aria-hidden": "true"
1341
+ }, (0, react.createElement)("path", { d: door.iconPath })));
1342
+ return (0, react.createElement)("span", { style: {
1343
+ display: "inline-flex",
1344
+ alignItems: "center",
1345
+ gap: "4px"
1346
+ } }, withTooltip(door.label, button), note !== null ? (0, react.createElement)("span", { className: "dsh-chapters_forkNote" }, note) : null);
1347
+ };
1348
+ }
1349
+ function apply(ctx) {
1350
+ injectStyles();
1351
+ const slots = ctx.slots;
1352
+ const remote = ctx.get("remote") ?? {};
1353
+ let sessions;
1354
+ try {
1355
+ sessions = ctx.get("sessions");
1356
+ } catch {
1357
+ sessions = void 0;
1358
+ }
1359
+ let uiWorkspace;
1360
+ try {
1361
+ uiWorkspace = ctx.get("uiWorkspace");
1362
+ } catch {
1363
+ uiWorkspace = void 0;
1364
+ }
1365
+ const currentSessionId = () => {
1366
+ try {
1367
+ const raw = window.localStorage.getItem("dsh.sessions.current");
1368
+ if (raw === null) return null;
1369
+ return JSON.parse(raw)?.sessionId ?? null;
1370
+ } catch {
1371
+ return null;
1372
+ }
1373
+ };
1374
+ const rows = DOOR_ACTIONS.map((door) => ({
1375
+ door,
1376
+ run: makeDoorRunner(remote, sessions, door, async (child) => {
1377
+ for (let i = 0; i < 8; i++) {
1378
+ if (currentSessionId() === child) return true;
1379
+ await new Promise((r) => setTimeout(r, 250));
1380
+ }
1381
+ return currentSessionId() === child;
1382
+ }, { ...uiWorkspace !== void 0 ? { navigator: uiWorkspace } : {} }),
1383
+ component: makeDoorAction(door)
1384
+ }));
1385
+ console.info(`[client:chapters] injecting ${String(rows.length)} assistant-actions door(s)`);
1386
+ const disposeDoors = slots.inject("conversation.chat.assistant-actions", () => {
1387
+ const effects = rows.map(({ door, run, component }) => slots.register({
1388
+ name: "conversation.chat.assistant-actions",
1389
+ id: door.id,
1390
+ label: door.label,
1391
+ inject: (sessionId, owner) => ({ run: async () => {
1392
+ let anchor = null;
1393
+ const mid = typeof owner?.messageId === "string" ? owner.messageId : "";
1394
+ if (mid !== "") try {
1395
+ const out = await runCommandText(remote, sessionId, `/chapters-anchor ${mid}`);
1396
+ const n = Number.parseInt(out.trim(), 10);
1397
+ if (Number.isFinite(n) && n > 0) anchor = n;
1398
+ } catch {}
1399
+ return run(sessionId, anchor);
1400
+ } })
1401
+ }, component));
1402
+ console.info(`[client:chapters] door factory ran: ${String(effects.length)} register effect(s), types: ${effects.map((e) => typeof e).join(",")}`);
1403
+ return effects;
1404
+ });
1405
+ console.info(`[client:chapters] assistant-actions inject returned ${typeof disposeDoors}`);
1406
+ slots.inject("conversation.input.dock", () => slots.register({
1407
+ name: "conversation.input.dock",
1408
+ id: "chapters-prompt",
1409
+ order: 30,
1410
+ inject: (sessionId) => ({ exec: (line) => runCommandText(remote, sessionId, line) })
1411
+ }, PromptDock));
1412
+ }
1413
+ exports.apply = apply;
1414
+ exports.inject = inject;
1415
+ return module.exports;
1416
+ }
1417
+ });
1418
+
1419
+ //# sourceMappingURL=client.js.map