@try-works/dsh-recursive-mode 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/cordis.patch.yml +15 -10
  2. package/lib/bootstrap.d.ts +9 -2
  3. package/lib/client/apply-guard.d.ts +14 -0
  4. package/lib/client/board.d.ts +5 -4
  5. package/lib/client/contract.d.ts +57 -24
  6. package/lib/client/host-api.d.ts +26 -0
  7. package/lib/client/index.d.ts +16 -9
  8. package/lib/client/inspector.d.ts +3 -3
  9. package/lib/client/slots.d.ts +4 -6
  10. package/lib/client/strip.d.ts +4 -3
  11. package/lib/client/use-live.d.ts +9 -0
  12. package/lib/client.js +252 -174
  13. package/lib/index.d.ts +11 -11
  14. package/lib/index.js +972 -4504
  15. package/lib/init-templates.d.ts +41 -0
  16. package/lib/live-route.d.ts +69 -0
  17. package/lib/phase-rules.d.ts +34 -0
  18. package/lib/runtime.d.ts +24 -17
  19. package/lib/snapshot.d.ts +11 -0
  20. package/package.json +4 -3
  21. package/preset/recursive/agent.cordis.yml +7 -4
  22. package/preset/recursive/preset.yml +1 -1
  23. package/references/agents-block.md +95 -0
  24. package/references/artifact-template.md +2574 -0
  25. package/references/bodies/claude.md +7 -0
  26. package/references/bodies/codex-agents.md +95 -0
  27. package/references/bodies/copilot.md +7 -0
  28. package/references/bodies/cursorrules.md +7 -0
  29. package/references/bodies/decisions.md +5 -0
  30. package/references/bodies/delegated-verification.md +49 -0
  31. package/references/bodies/memory-router.md +45 -0
  32. package/references/bodies/phase8-skill-memory.md +48 -0
  33. package/references/bodies/plans-bridge.md +43 -0
  34. package/references/bodies/recursive-agents-router.md +64 -0
  35. package/references/bodies/skill-discovery.md +51 -0
  36. package/references/bodies/skill-memory-router.md +42 -0
  37. package/references/bodies/state.md +5 -0
  38. package/references/bootstrap/RECURSIVE.md +2362 -0
  39. package/references/scripts/__pycache__/recursive_phase_rules.cpython-314.pyc +0 -0
  40. package/references/scripts/lint-recursive-run.ps1 +25 -0
  41. package/references/scripts/lint-recursive-run.py +2870 -0
  42. package/references/scripts/recursive-closeout.ps1 +38 -0
  43. package/references/scripts/recursive-closeout.py +541 -0
  44. package/references/scripts/recursive-init.ps1 +515 -0
  45. package/references/scripts/recursive-init.py +356 -0
  46. package/references/scripts/recursive-lock.ps1 +26 -0
  47. package/references/scripts/recursive-lock.py +302 -0
  48. package/references/scripts/recursive-review-bundle.ps1 +58 -0
  49. package/references/scripts/recursive-review-bundle.py +503 -0
  50. package/references/scripts/recursive-router-cli-configure.ps1 +2 -0
  51. package/references/scripts/recursive-router-cli-configure.py +9 -0
  52. package/references/scripts/recursive-router-cli-init.ps1 +2 -0
  53. package/references/scripts/recursive-router-cli-init.py +9 -0
  54. package/references/scripts/recursive-router-cli-invoke.ps1 +2 -0
  55. package/references/scripts/recursive-router-cli-invoke.py +9 -0
  56. package/references/scripts/recursive-router-cli-probe.ps1 +2 -0
  57. package/references/scripts/recursive-router-cli-probe.py +9 -0
  58. package/references/scripts/recursive-router-cli-resolve.ps1 +2 -0
  59. package/references/scripts/recursive-router-cli-resolve.py +9 -0
  60. package/references/scripts/recursive-router-cli-validate.ps1 +2 -0
  61. package/references/scripts/recursive-router-cli-validate.py +9 -0
  62. package/references/scripts/recursive-router-configure.ps1 +27 -0
  63. package/references/scripts/recursive-router-configure.py +74 -0
  64. package/references/scripts/recursive-router-init.ps1 +17 -0
  65. package/references/scripts/recursive-router-init.py +29 -0
  66. package/references/scripts/recursive-router-invoke.ps1 +47 -0
  67. package/references/scripts/recursive-router-invoke.py +103 -0
  68. package/references/scripts/recursive-router-probe.ps1 +25 -0
  69. package/references/scripts/recursive-router-probe.py +44 -0
  70. package/references/scripts/recursive-router-resolve.ps1 +26 -0
  71. package/references/scripts/recursive-router-resolve.py +46 -0
  72. package/references/scripts/recursive-router-validate.ps1 +17 -0
  73. package/references/scripts/recursive-router-validate.py +27 -0
  74. package/references/scripts/recursive-status.ps1 +23 -0
  75. package/references/scripts/recursive-status.py +2124 -0
  76. package/references/scripts/recursive-subagent-action.ps1 +98 -0
  77. package/references/scripts/recursive-subagent-action.py +197 -0
  78. package/references/scripts/recursive-training-extract.ps1 +23 -0
  79. package/references/scripts/recursive-training-extract.py +99 -0
  80. package/references/scripts/recursive-training-grpo.ps1 +52 -0
  81. package/references/scripts/recursive-training-grpo.py +1341 -0
  82. package/references/scripts/recursive-training-loader.ps1 +78 -0
  83. package/references/scripts/recursive-training-loader.py +558 -0
  84. package/references/scripts/recursive-training-mcp.ps1 +29 -0
  85. package/references/scripts/recursive-training-mcp.py +261 -0
  86. package/references/scripts/recursive-training-phase8-trigger.ps1 +55 -0
  87. package/references/scripts/recursive-training-phase8-trigger.py +137 -0
  88. package/references/scripts/recursive-training-sync.ps1 +29 -0
  89. package/references/scripts/recursive-training-sync.py +183 -0
  90. package/references/scripts/recursive_phase_rules.py +367 -0
  91. package/references/scripts/recursive_router_cli_lib.py +2 -0
  92. package/references/scripts/recursive_router_lib.py +2282 -0
  93. package/references/scripts/verify-locks.ps1 +25 -0
  94. package/references/scripts/verify-locks.py +353 -0
  95. package/scripts/__pycache__/lint-recursive-run.cpython-314.pyc +0 -0
  96. package/scripts/__pycache__/recursive_phase_rules.cpython-314.pyc +0 -0
  97. package/scripts/install-preset.cmd +7 -0
  98. package/scripts/install-preset.js +101 -0
  99. package/scripts/lint-recursive-run.py +7 -5
  100. package/scripts/test-recursive-mode-smoke.ts +29 -28
  101. package/src/bootstrap.ts +253 -39
  102. package/src/client/apply-guard.ts +23 -0
  103. package/src/client/board.tsx +0 -0
  104. package/src/client/contract.ts +72 -24
  105. package/src/client/host-api.ts +90 -0
  106. package/src/client/index.ts +22 -11
  107. package/src/client/inspector.tsx +7 -9
  108. package/src/client/slots.ts +70 -26
  109. package/src/client/strip.tsx +9 -9
  110. package/src/client/use-live.ts +52 -0
  111. package/src/index.ts +89 -146
  112. package/src/init-templates.ts +276 -0
  113. package/src/live-route.ts +163 -0
  114. package/src/phase-rules.ts +262 -0
  115. package/src/policy.ts +28 -4
  116. package/src/runtime.ts +129 -77
  117. package/src/snapshot.ts +104 -0
  118. package/lib/client/node.d.ts +0 -71
  119. package/lib/events.d.ts +0 -173
  120. package/lib/projection.d.ts +0 -29
  121. package/src/client/node.ts +0 -156
  122. package/src/events.ts +0 -173
  123. package/src/projection.ts +0 -237
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Init templates (R5): canonical-parity port of recursive-init.py's
3
+ * requirements_content / worktree_content / detect_git_context, plus full
4
+ * per-phase templates derived from the canonical artifact-template sections
5
+ * (get_artifact_required_sections) + TODO + Coverage/Approval FAIL gates.
6
+ *
7
+ * 00-requirements.md + 00-worktree.md are byte-identical to canonical
8
+ * recursive-init.py output (LF, no BOM). Later phases are structural: every
9
+ * required section heading + the two FAIL gates, so lint fails honestly until
10
+ * the phase is actually worked.
11
+ */
12
+ import { execFileSync } from 'node:child_process'
13
+ import { join } from 'node:path'
14
+ import { getArtifactRequiredSections } from './phase-rules.ts'
15
+
16
+ export interface GitContext {
17
+ baselineType: string
18
+ baselineReference: string
19
+ comparisonReference: string
20
+ normalizedBaseline: string
21
+ normalizedComparison: string
22
+ normalizedDiffCommand: string
23
+ baseBranch: string
24
+ worktreeBranch: string
25
+ baseCommit: string
26
+ notes: string
27
+ }
28
+
29
+ /**
30
+ * detect_git_context(): canonical parity. Returns {context, error} — never
31
+ * throws on missing git; error is surfaced in the worktree template note.
32
+ */
33
+ export function detectGitContext(repoRoot: string): { context: Partial<GitContext>; error: string | null } {
34
+ const run = (args: string[]): string | null => {
35
+ try {
36
+ return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
37
+ } catch {
38
+ return null
39
+ }
40
+ }
41
+ const headSha = run(['rev-parse', '--verify', 'HEAD^{commit}'])
42
+ if (!headSha) {
43
+ return { context: {}, error: 'Unable to resolve HEAD commit for Phase 0 diff basis prefill: git rev-parse returned no output' }
44
+ }
45
+ const branch = run(['symbolic-ref', '--quiet', '--short', 'HEAD']) || '(detached HEAD)'
46
+ const diffCommand = 'git diff --name-only ' + headSha
47
+ return {
48
+ context: {
49
+ baselineType: 'local commit',
50
+ baselineReference: headSha,
51
+ comparisonReference: 'working-tree',
52
+ normalizedBaseline: headSha,
53
+ normalizedComparison: 'working-tree',
54
+ normalizedDiffCommand: diffCommand,
55
+ baseBranch: branch,
56
+ worktreeBranch: branch,
57
+ baseCommit: headSha,
58
+ notes: 'recursive-init prefilled this executable diff basis from the current HEAD commit. If Phase 0 later changes the chosen baseline, update every diff-basis field and rerun lint before locking.',
59
+ },
60
+ error: null,
61
+ }
62
+ }
63
+
64
+ /**
65
+ * requirements_content(run_id, template, from_issue): byte-identical to the
66
+ * canonical recursive-init.py function (LF, template flavor in Scope note).
67
+ */
68
+ export function requirementsContent(runId: string, template = 'feature', fromIssue = ''): string {
69
+ const inputs = ['- [chat summary or source notes if captured in repo]']
70
+ if (fromIssue.trim()) inputs.push('- Source: ' + fromIssue.trim())
71
+ const inputsBlock = inputs.join('\n')
72
+ return [
73
+ 'Run: `/.recursive/run/' + runId + '/`',
74
+ 'Phase: `00 Requirements`',
75
+ 'Status: `DRAFT`',
76
+ 'Workflow version: `recursive-mode-audit-v2`',
77
+ 'Inputs:',
78
+ inputsBlock,
79
+ 'Outputs:',
80
+ '- `/.recursive/run/' + runId + '/00-requirements.md`',
81
+ 'Scope note: This document defines stable requirement identifiers and acceptance criteria. (Template: ' + template + ')',
82
+ '',
83
+ '## TODO',
84
+ '',
85
+ '- [ ] Elicit requirements from user/context',
86
+ '- [ ] Define requirement identifiers (R1, R2, ...)',
87
+ '- [ ] Write acceptance criteria for each requirement',
88
+ '- [ ] Document out of scope items (OOS1, OOS2, ...)',
89
+ '- [ ] List constraints and assumptions',
90
+ '- [ ] Complete Coverage Gate checklist',
91
+ '- [ ] Complete Approval Gate checklist',
92
+ '',
93
+ '## Requirements',
94
+ '',
95
+ '### `R1` <short title>',
96
+ '',
97
+ 'Description:',
98
+ 'Acceptance criteria:',
99
+ '- [observable condition 1]',
100
+ '- [observable condition 2]',
101
+ '',
102
+ '## Out of Scope',
103
+ '',
104
+ '- `OOS1`: ...',
105
+ '',
106
+ '## Constraints',
107
+ '',
108
+ '- ...',
109
+ '',
110
+ '## Coverage Gate',
111
+ '...',
112
+ 'Coverage: FAIL',
113
+ '',
114
+ '## Approval Gate',
115
+ '...',
116
+ 'Approval: FAIL',
117
+ ].join('\n') + '\n'
118
+ }
119
+
120
+ /**
121
+ * worktree_content(run_id, repo_root, git_context, prefill_error): byte-identical
122
+ * to the canonical recursive-init.py function.
123
+ */
124
+ export function worktreeContent(runId: string, repoRoot: string, git: Partial<GitContext>, prefillError: string | null): string {
125
+ const baseBranch = git.baseBranch ?? '(resolve during Phase 0)'
126
+ const worktreeBranch = git.worktreeBranch ?? '(resolve during Phase 0)'
127
+ const baseCommit = git.baseCommit ?? '<resolve-before-locking>'
128
+ const baselineType = git.baselineType ?? 'local commit'
129
+ const baselineReference = git.baselineReference ?? '<resolve-before-locking>'
130
+ const comparisonReference = git.comparisonReference ?? 'working-tree'
131
+ const normalizedBaseline = git.normalizedBaseline ?? '<resolve-before-locking>'
132
+ const normalizedComparison = git.normalizedComparison ?? 'working-tree'
133
+ const normalizedDiffCommand = git.normalizedDiffCommand ?? 'git diff --name-only <resolve-before-locking>'
134
+ const notes = git.notes ?? 'Populate an executable diff basis before locking Phase 0. Lint and lock will fail until the normalized basis matches live git state.'
135
+ const setupNote = prefillError
136
+ ? 'recursive-init could not prefill the Phase 0 diff basis automatically: ' + prefillError
137
+ : 'recursive-init detected the current repository context and prefilled the Phase 0 diff basis.'
138
+ return [
139
+ 'Run: `/.recursive/run/' + runId + '/`',
140
+ 'Phase: `00 Worktree`',
141
+ 'Status: `DRAFT`',
142
+ 'Inputs:',
143
+ '- `/.recursive/run/' + runId + '/00-requirements.md`',
144
+ '- Current git repository state',
145
+ 'Outputs:',
146
+ '- `/.recursive/run/' + runId + '/00-worktree.md`',
147
+ 'Scope note: This document records the Phase 0 worktree context and the executable diff basis that all later audited phases must reuse.',
148
+ '',
149
+ '## TODO',
150
+ '',
151
+ '- [ ] Confirm the selected worktree location and isolation approach',
152
+ '- [ ] Confirm the base branch and worktree branch values',
153
+ '- [ ] Run setup and verify the clean test baseline',
154
+ '- [ ] Confirm the diff basis fields still match live git state',
155
+ '- [ ] Complete Coverage Gate checklist',
156
+ '- [ ] Complete Approval Gate checklist',
157
+ '',
158
+ '## Directory Selection',
159
+ '',
160
+ '- Repository root: `' + repoRoot + '`',
161
+ '- Preferred worktree location: `.worktrees/' + runId + '/`',
162
+ '- Update this section with the actual selected location before locking Phase 0.',
163
+ '',
164
+ '## Safety Verification',
165
+ '',
166
+ '- Original branch / repo state observed at init time: `' + baseBranch + '`',
167
+ '- Isolation still must be confirmed after the actual worktree is created.',
168
+ '',
169
+ '## Worktree Creation',
170
+ '',
171
+ '- Intended worktree branch: `' + worktreeBranch + '`',
172
+ '- Record the actual worktree creation command and output before locking.',
173
+ '',
174
+ '## Main Branch Protection',
175
+ '',
176
+ '- Base branch source of truth at init time: `' + baseBranch + '`',
177
+ '- Explicitly document any deviation from isolated worktree execution before locking.',
178
+ '',
179
+ '## Project Setup',
180
+ '',
181
+ '- Init-time note: ' + setupNote,
182
+ '- Replace this section with the actual setup commands and results during Phase 0.',
183
+ '',
184
+ '## Test Baseline Verification',
185
+ '',
186
+ '- Record the baseline commands and results after setup completes.',
187
+ '',
188
+ '## Worktree Context',
189
+ '',
190
+ '- Base branch: `' + baseBranch + '`',
191
+ '- Worktree branch: `' + worktreeBranch + '`',
192
+ '- Base commit: `' + baseCommit + '`',
193
+ '',
194
+ '## Diff Basis For Later Audits',
195
+ '',
196
+ '- Baseline type: `' + baselineType + '`',
197
+ '- Baseline reference: `' + baselineReference + '`',
198
+ '- Comparison reference: `' + comparisonReference + '`',
199
+ '- Normalized baseline: `' + normalizedBaseline + '`',
200
+ '- Normalized comparison: `' + normalizedComparison + '`',
201
+ '- Normalized diff command: `' + normalizedDiffCommand + '`',
202
+ '- Base branch: `' + baseBranch + '`',
203
+ '- Worktree branch: `' + worktreeBranch + '`',
204
+ '- Diff basis notes: `' + notes + '`',
205
+ '',
206
+ '## Traceability',
207
+ '',
208
+ '- Recursive workflow safety -> Phase 0 records a reusable executable diff basis before audited phases begin.',
209
+ '',
210
+ '## Coverage Gate',
211
+ '',
212
+ '- [ ] Worktree location and branch context are recorded',
213
+ '- [ ] Setup and clean baseline verification are recorded',
214
+ '- [ ] Diff basis fields are executable against live git state',
215
+ '',
216
+ 'Coverage: FAIL',
217
+ '',
218
+ '## Approval Gate',
219
+ '',
220
+ '- [ ] Phase 0 context is ready for downstream audited phases',
221
+ '- [ ] No unresolved setup or diff-basis inconsistencies remain',
222
+ '',
223
+ 'Approval: FAIL',
224
+ ].join('\n') + '\n'
225
+ }
226
+
227
+ /**
228
+ * Later-phase template: required sections (canonical get_artifact_required_
229
+ * sections) as ## headings + TODO + FAIL gates. Structural scaffold: the phase
230
+ * author fills each section; lint stays FAIL until Coverage/Approval are set.
231
+ */
232
+ export function laterPhaseContent(runId: string, fileName: string, workflowProfile = 'recursive-mode-audit-v2'): string {
233
+ const sections = getArtifactRequiredSections(fileName, workflowProfile)
234
+ const lines = [
235
+ 'Run: `/.recursive/run/' + runId + '/`',
236
+ 'Phase: `' + fileName.replace(/\.md$/, '').replace(/^0?/, '') + '`',
237
+ 'Status: `DRAFT`',
238
+ 'Workflow version: `' + workflowProfile + '`',
239
+ 'Inputs:',
240
+ '- (list upstream artifacts re-read for this phase)',
241
+ 'Outputs:',
242
+ '- `/.recursive/run/' + runId + '/' + fileName + '`',
243
+ 'Scope note: Scaffold generated by the recursive-mode plugin (R5). Fill every required section before lint.',
244
+ '',
245
+ '## TODO',
246
+ '',
247
+ '- [ ] Work through every required section below',
248
+ '- [ ] Record evidence and traceability',
249
+ '- [ ] Complete Coverage Gate checklist',
250
+ '- [ ] Complete Approval Gate checklist',
251
+ ];
252
+ for (const section of sections) {
253
+ if (section === 'TODO') continue
254
+ lines.push('', '## ' + section, '', '- (fill)')
255
+ }
256
+ lines.push('', '## Coverage Gate', '', 'Coverage: FAIL', '', '## Approval Gate', '', 'Approval: FAIL', '')
257
+ return lines.join('\n')
258
+ }
259
+
260
+ /**
261
+ * Scaffold dirs under a run (addenda/subagents/router-prompts/evidence + subs).
262
+ * Mirrors canonical recursive-init.py's ensure_directory sequence.
263
+ */
264
+ export const RUN_SCAFFOLD_DIRS = [
265
+ 'addenda',
266
+ 'subagents',
267
+ 'router-prompts',
268
+ 'evidence',
269
+ 'evidence/screenshots',
270
+ 'evidence/logs',
271
+ 'evidence/perf',
272
+ 'evidence/traces',
273
+ 'evidence/review-bundles',
274
+ 'evidence/router',
275
+ 'evidence/other',
276
+ ]
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Live recursive board/strip route (SP2 R1): host HTTP + SSE serving the
3
+ * filesystem fold, keyed per-WORKSPACE. The board/strip read this instead of
4
+ * useProjection('recursive'); cold resume and every GET are a fresh fs read.
5
+ *
6
+ * Keying (creator steer): the client passes its session cwd verbatim; the HOST
7
+ * resolves the recursive control-plane root server-side (cwd == workspace root,
8
+ * cwd == subdir, cwd == repo root). Multiple sessions in one workspace collapse
9
+ * to one fold; a workspace switch shows the new root.
10
+ *
11
+ * R9 read-only: GET state + GET events only. No POST/action route; the client
12
+ * never mutates the host.
13
+ *
14
+ * Registration is mountOnce-global because apply() runs PER-SESSION while
15
+ * WebServer.register throws on a duplicate (kind, path). The first mount wins;
16
+ * later mounts no-op (the state is per-workspace, so per-session registration
17
+ * would be both wrong and a crash).
18
+ */
19
+ import type { IncomingMessage, ServerResponse } from 'node:http'
20
+ import type { RecursiveProjection } from './types.ts'
21
+
22
+ /** API prefix the board/strip fetch. */
23
+ export const RECURSIVE_API_PREFIX = '/.recursive/api'
24
+
25
+ /** The host seam the route builder needs: root resolution + the fs fold + a revision. */
26
+ export interface RecursiveRouteHost {
27
+ /**
28
+ * Resolve the recursive control-plane root. sessionId is PRIMARY (the host
29
+ * prefers the attached session header cwd — never trusts a client path); cwd
30
+ * is a fallback hint only while the session is hydrating / for headless callers.
31
+ */
32
+ resolveRoot(sessionId: string | undefined, cwd: string): Promise<string | null>
33
+ /** Fresh filesystem fold for one workspace root (per-workspace state). */
34
+ snapshot(root: string): Promise<RecursiveProjection> | RecursiveProjection
35
+ /** Monotone revision; bump whenever the fs fold changes (SSE frame + state). */
36
+ revision(root: string): number
37
+ }
38
+
39
+ /** The state payload the board/strip read. */
40
+ export interface RecursiveStatePayload {
41
+ root: string | null
42
+ projection: RecursiveProjection
43
+ revision: number
44
+ }
45
+
46
+ const HEARTBEAT_MS = 15_000
47
+
48
+ function json(res: ServerResponse, status: number, body: unknown): void {
49
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
50
+ res.end(JSON.stringify(body))
51
+ }
52
+
53
+ /** Same-origin browser tripwire (NOT an authority check; the loopback socket is the real boundary). */
54
+ function browserMarker(req: IncomingMessage): boolean {
55
+ const headers = req.headers ?? {}
56
+ const site = headers['sec-fetch-site']
57
+ return site === 'same-origin' || typeof headers.origin === 'string'
58
+ }
59
+
60
+ /** Read a query param (URLSearchParams tolerates a missing/duplicate value). */
61
+ function queryOf(req: IncomingMessage, name: string): string {
62
+ const url = req.url ?? ''
63
+ const q = url.indexOf('?')
64
+ if (q < 0) return ''
65
+ return new URLSearchParams(url.slice(q + 1)).get(name) ?? ''
66
+ }
67
+
68
+ /**
69
+ * Build the two read-only routes. Returns [state, events] in registration order.
70
+ * @param host - the resolved-root + fs-fold seam (the RecursiveRuntime adapter).
71
+ */
72
+ export function makeRecursiveRoutes(host: RecursiveRouteHost): readonly { kind: 'exact'; path: string; handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void> }[] {
73
+ const state: { kind: 'exact'; path: string; handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void> } = {
74
+ kind: 'exact',
75
+ path: RECURSIVE_API_PREFIX + '/state',
76
+ handler: async (req, res) => {
77
+ if (req.method !== 'GET') { res.writeHead(405); res.end(); return }
78
+ if (!browserMarker(req)) { res.writeHead(403); res.end(); return }
79
+ const sessionId = queryOf(req, 'sessionId') || undefined
80
+ const cwd = queryOf(req, 'cwd')
81
+ const root = sessionId === undefined && cwd === '' ? null : await host.resolveRoot(sessionId, cwd)
82
+ if (root === null) {
83
+ json(res, 200, { root: null, projection: {}, revision: 0 } as RecursiveStatePayload)
84
+ return
85
+ }
86
+ json(res, 200, { root, projection: await host.snapshot(root), revision: host.revision(root) } as RecursiveStatePayload)
87
+ },
88
+ }
89
+ const events: { kind: 'exact'; path: string; handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void> } = {
90
+ kind: 'exact',
91
+ path: RECURSIVE_API_PREFIX + '/events',
92
+ handler: async (req, res) => {
93
+ if (req.method !== 'GET') { res.writeHead(405); res.end(); return }
94
+ if (!browserMarker(req)) { res.writeHead(403); res.end(); return }
95
+ const sessionId = queryOf(req, 'sessionId') || undefined
96
+ const cwd = queryOf(req, 'cwd')
97
+ const root = sessionId === undefined && cwd === '' ? null : await host.resolveRoot(sessionId, cwd)
98
+ if (root === null) { res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache', connection: 'keep-alive' }); res.write('data: {"root":null,"projection":{},"revision":0}\n\n'); res.end(); return }
99
+ res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache', connection: 'keep-alive' })
100
+ const push = (): void => {
101
+ const payload = { root, projection: host.snapshot(root), revision: host.revision(root) }
102
+ res.write('data: ' + JSON.stringify(payload) + '\n\n')
103
+ }
104
+ const heartbeat = setInterval(() => { res.write(': ping\n\n') }, HEARTBEAT_MS)
105
+ const close = (): void => { clearInterval(heartbeat) }
106
+ req.once('close', close)
107
+ res.once('close', close)
108
+ await push()
109
+ },
110
+ }
111
+ return [state, events]
112
+ }
113
+
114
+ /**
115
+ * Register the routes at most ONCE per process. WebServer.register throws on a
116
+ * duplicate (kind, path), and apply() runs per-session, so later mounts MUST
117
+ * no-op. The registry rides a global symbol so two module instances of the same
118
+ * package share one verdict; the unmarker rides ctx.effect (cordis runs the
119
+ * effect callback immediately and treats its return as the disposer).
120
+ */
121
+ const MOUNTED = Symbol.for('dsh-recursive-mode.mounted')
122
+
123
+ interface MountRegistry {
124
+ [MOUNTED]?: Set<string>
125
+ }
126
+
127
+ function mountedSet(): Set<string> {
128
+ const registry = globalThis as MountRegistry
129
+ return (registry[MOUNTED] ??= new Set())
130
+ }
131
+
132
+ export interface WebServerLike {
133
+ register(route: { kind: string; path: string; handler: unknown }): () => void
134
+ }
135
+
136
+ /**
137
+ * mountOnce wrapper for the route registration. Returns the disposer of the
138
+ * registration (or a no-op for a later mount).
139
+ * @param packageName - npm package identity (every install source shares it).
140
+ * @param makeRoutes - builds the routes (called once, on the first mount).
141
+ * @param webServer - the host webserver service (or a structural fake).
142
+ */
143
+ export function mountRecursiveRoutesOnce(
144
+ packageName: string,
145
+ makeRoutes: () => readonly { kind: string; path: string; handler: unknown }[],
146
+ webServer: WebServerLike,
147
+ ): () => void {
148
+ const mounted = mountedSet()
149
+ if (mounted.has(packageName)) return () => {}
150
+ mounted.add(packageName)
151
+ const disposers: Array<() => void> = []
152
+ try {
153
+ for (const route of makeRoutes()) disposers.push(webServer.register(route))
154
+ } catch (error) {
155
+ mounted.delete(packageName)
156
+ for (const dispose of disposers) dispose()
157
+ throw error
158
+ }
159
+ return () => {
160
+ for (const dispose of disposers) dispose()
161
+ mounted.delete(packageName)
162
+ }
163
+ }