@try-works/dsh-recursive-mode 0.2.2 → 0.2.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.
package/lib/index.js CHANGED
@@ -6661,14 +6661,30 @@ function queryOf(req, name) {
6661
6661
  if (q < 0) return "";
6662
6662
  return new URLSearchParams(url.slice(q + 1)).get(name) ?? "";
6663
6663
  }
6664
+ /** Phase-doc basename allowlist (the run artifact sequence: single *.md, no subdirs). */
6665
+ const PHASE_DOC_FILES = new Set(RUN_ARTIFACT_SEQUENCE);
6666
+ /** runId/file safety: alphanumerics, dot, underscore, dash only; no '..', no separators. */
6667
+ const DOC_SAFE_RE = /^[A-Za-z0-9._-]+$/;
6668
+ /** Invalid runId or file name (path traversal / subdir / non-phase doc) — reject. */
6669
+ function docTargetError(runId, file) {
6670
+ if (!DOC_SAFE_RE.test(runId) || runId.includes("..")) return "invalid runId";
6671
+ if (!DOC_SAFE_RE.test(file) || file.includes("..") || file.includes("/") || file.includes("\\")) return "invalid file";
6672
+ if (!file.endsWith(".md")) return "invalid file: must be a .md phase doc";
6673
+ if (!PHASE_DOC_FILES.has(file)) return "invalid file: not a phase doc";
6674
+ return null;
6675
+ }
6664
6676
  /**
6665
- * Build the two read-only routes. Returns [state, events] in registration order.
6666
- * @param host - the resolved-root + fs-fold seam (the RecursiveRuntime adapter).
6677
+ * The lazy per-phase doc route (0.2.4): GET the raw markdown of one run phase
6678
+ * doc, read ON DEMAND from the filesystem. NOT part of the /state or /events
6679
+ * projection payloads (they stay fold-only). Same browser-marker tripwire as
6680
+ * state/events; the client root is re-validated by the host (never trusted:
6681
+ * an unknown root -> 400), then the resolved doc path is containment-checked
6682
+ * under join(root, '.recursive', 'run', runId).
6667
6683
  */
6668
- function makeRecursiveRoutes(host) {
6669
- return [{
6684
+ function docRoute(host) {
6685
+ return {
6670
6686
  kind: "exact",
6671
- path: "/.recursive/api/state",
6687
+ path: "/.recursive/api/doc",
6672
6688
  handler: async (req, res) => {
6673
6689
  if (req.method !== "GET") {
6674
6690
  res.writeHead(405);
@@ -6680,75 +6696,142 @@ function makeRecursiveRoutes(host) {
6680
6696
  res.end();
6681
6697
  return;
6682
6698
  }
6683
- const sessionId = queryOf(req, "sessionId") || void 0;
6684
- const cwd = queryOf(req, "cwd");
6685
- const root = sessionId === void 0 && cwd === "" ? null : await host.resolveRoot(sessionId, cwd);
6686
- if (root === null) {
6687
- json(res, 200, {
6688
- root: null,
6689
- projection: {},
6690
- revision: 0
6699
+ const root = queryOf(req, "root");
6700
+ const runId = queryOf(req, "runId");
6701
+ const file = queryOf(req, "file");
6702
+ const targetError = docTargetError(runId, file);
6703
+ if (root === "" || targetError !== null) {
6704
+ json(res, 400, {
6705
+ ok: false,
6706
+ error: targetError ?? "missing root"
6691
6707
  });
6692
6708
  return;
6693
6709
  }
6694
- json(res, 200, {
6695
- root,
6696
- projection: await host.snapshot(root),
6697
- revision: host.revision(root)
6698
- });
6699
- }
6700
- }, {
6701
- kind: "exact",
6702
- path: "/.recursive/api/events",
6703
- handler: async (req, res) => {
6704
- if (req.method !== "GET") {
6705
- res.writeHead(405);
6706
- res.end();
6710
+ const resolvedRoot = await host.resolveRoot(void 0, root);
6711
+ if (resolvedRoot === null || resolve(resolvedRoot) !== resolve(root)) {
6712
+ json(res, 400, {
6713
+ ok: false,
6714
+ error: "root is not a known workspace"
6715
+ });
6707
6716
  return;
6708
6717
  }
6709
- if (!browserMarker(req)) {
6710
- res.writeHead(403);
6711
- res.end();
6718
+ const runBase = resolve(root, ".recursive", "run", runId);
6719
+ const docPath = resolve(runBase, file);
6720
+ if (docPath === runBase || !docPath.startsWith(runBase + sep)) {
6721
+ json(res, 400, {
6722
+ ok: false,
6723
+ error: "doc path escapes the run dir"
6724
+ });
6712
6725
  return;
6713
6726
  }
6714
- const sessionId = queryOf(req, "sessionId") || void 0;
6715
- const cwd = queryOf(req, "cwd");
6716
- const root = sessionId === void 0 && cwd === "" ? null : await host.resolveRoot(sessionId, cwd);
6717
- if (root === null) {
6718
- res.writeHead(200, {
6719
- "content-type": "text/event-stream; charset=utf-8",
6720
- "cache-control": "no-cache",
6721
- connection: "keep-alive"
6727
+ if (!existsSync(docPath)) {
6728
+ json(res, 404, {
6729
+ ok: false,
6730
+ error: "phase doc not found"
6722
6731
  });
6723
- res.write("data: {\"root\":null,\"projection\":{},\"revision\":0}\n\n");
6724
- res.end();
6725
6732
  return;
6726
6733
  }
6734
+ const text = readFileSync(docPath, "utf8");
6727
6735
  res.writeHead(200, {
6728
- "content-type": "text/event-stream; charset=utf-8",
6729
- "cache-control": "no-cache",
6730
- connection: "keep-alive"
6736
+ "content-type": "text/markdown; charset=utf-8",
6737
+ "cache-control": "no-store"
6731
6738
  });
6732
- const push = async () => {
6733
- const projection = await host.snapshot(root);
6734
- const payload = {
6739
+ res.end(text);
6740
+ }
6741
+ };
6742
+ }
6743
+ /**
6744
+ * Build the read-only routes. Returns [state, events, doc] in registration order.
6745
+ * @param host - the resolved-root + fs-fold seam (the RecursiveRuntime adapter).
6746
+ */
6747
+ function makeRecursiveRoutes(host) {
6748
+ return [
6749
+ {
6750
+ kind: "exact",
6751
+ path: "/.recursive/api/state",
6752
+ handler: async (req, res) => {
6753
+ if (req.method !== "GET") {
6754
+ res.writeHead(405);
6755
+ res.end();
6756
+ return;
6757
+ }
6758
+ if (!browserMarker(req)) {
6759
+ res.writeHead(403);
6760
+ res.end();
6761
+ return;
6762
+ }
6763
+ const sessionId = queryOf(req, "sessionId") || void 0;
6764
+ const cwd = queryOf(req, "cwd");
6765
+ const root = sessionId === void 0 && cwd === "" ? null : await host.resolveRoot(sessionId, cwd);
6766
+ if (root === null) {
6767
+ json(res, 200, {
6768
+ root: null,
6769
+ projection: {},
6770
+ revision: 0
6771
+ });
6772
+ return;
6773
+ }
6774
+ json(res, 200, {
6735
6775
  root,
6736
- projection,
6776
+ projection: await host.snapshot(root),
6737
6777
  revision: host.revision(root)
6778
+ });
6779
+ }
6780
+ },
6781
+ {
6782
+ kind: "exact",
6783
+ path: "/.recursive/api/events",
6784
+ handler: async (req, res) => {
6785
+ if (req.method !== "GET") {
6786
+ res.writeHead(405);
6787
+ res.end();
6788
+ return;
6789
+ }
6790
+ if (!browserMarker(req)) {
6791
+ res.writeHead(403);
6792
+ res.end();
6793
+ return;
6794
+ }
6795
+ const sessionId = queryOf(req, "sessionId") || void 0;
6796
+ const cwd = queryOf(req, "cwd");
6797
+ const root = sessionId === void 0 && cwd === "" ? null : await host.resolveRoot(sessionId, cwd);
6798
+ if (root === null) {
6799
+ res.writeHead(200, {
6800
+ "content-type": "text/event-stream; charset=utf-8",
6801
+ "cache-control": "no-cache",
6802
+ connection: "keep-alive"
6803
+ });
6804
+ res.write("data: {\"root\":null,\"projection\":{},\"revision\":0}\n\n");
6805
+ res.end();
6806
+ return;
6807
+ }
6808
+ res.writeHead(200, {
6809
+ "content-type": "text/event-stream; charset=utf-8",
6810
+ "cache-control": "no-cache",
6811
+ connection: "keep-alive"
6812
+ });
6813
+ const push = async () => {
6814
+ const projection = await host.snapshot(root);
6815
+ const payload = {
6816
+ root,
6817
+ projection,
6818
+ revision: host.revision(root)
6819
+ };
6820
+ res.write("data: " + JSON.stringify(payload) + "\n\n");
6738
6821
  };
6739
- res.write("data: " + JSON.stringify(payload) + "\n\n");
6740
- };
6741
- const heartbeat = setInterval(() => {
6742
- res.write(": ping\n\n");
6743
- }, HEARTBEAT_MS);
6744
- const close = () => {
6745
- clearInterval(heartbeat);
6746
- };
6747
- req.once("close", close);
6748
- res.once("close", close);
6749
- await push();
6750
- }
6751
- }];
6822
+ const heartbeat = setInterval(() => {
6823
+ res.write(": ping\n\n");
6824
+ }, HEARTBEAT_MS);
6825
+ const close = () => {
6826
+ clearInterval(heartbeat);
6827
+ };
6828
+ req.once("close", close);
6829
+ res.once("close", close);
6830
+ await push();
6831
+ }
6832
+ },
6833
+ docRoute(host)
6834
+ ];
6752
6835
  }
6753
6836
  /**
6754
6837
  * Register the routes at most ONCE per process. WebServer.register throws on a
@@ -40,7 +40,7 @@ export interface RecursiveStatePayload {
40
40
  revision: number;
41
41
  }
42
42
  /**
43
- * Build the two read-only routes. Returns [state, events] in registration order.
43
+ * Build the read-only routes. Returns [state, events, doc] in registration order.
44
44
  * @param host - the resolved-root + fs-fold seam (the RecursiveRuntime adapter).
45
45
  */
46
46
  export declare function makeRecursiveRoutes(host: RecursiveRouteHost): readonly {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@try-works/dsh-recursive-mode",
3
3
  "description": "recursive-mode workflow as a DeepSeek Harness bundle: RecursiveRuntime service + recursive_status tool",
4
- "version": "0.2.2",
4
+ "version": "0.2.4",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -32,10 +32,16 @@ export interface BoardProps {
32
32
  workspacePath?: string
33
33
  onOpenInspector?: (selection: BoardSelection) => void
34
34
  onClose?: () => void
35
+ /**
36
+ * 'overlay' (default) renders the board as a full-viewport fixed overlay
37
+ * (shell.overlay / launcher). 'view' renders it inline, filling its parent
38
+ * (a conversation.view tab), with no close button and no fixed positioning.
39
+ */
40
+ variant?: 'overlay' | 'view'
35
41
  }
36
42
 
37
- function closeButton(onClose: (() => void) | undefined) {
38
- if (onClose === undefined) return null
43
+ function closeButton(onClose: (() => void) | undefined, variant: 'overlay' | 'view') {
44
+ if (onClose === undefined || variant === 'view') return null
39
45
  return createElement('button', { type: 'button', className: 'rec-close', onClick: onClose, title: 'Close', 'aria-label': 'Close' }, '×')
40
46
  }
41
47
 
@@ -49,7 +55,7 @@ function solidPill(kind: ReturnType<typeof cardPill>) {
49
55
  return createElement('span', { className: 'rec-pill', 'data-pill': kind }, PILL_LABELS[kind])
50
56
  }
51
57
 
52
- export function Board({ snapshot, workspacePath, onOpenInspector, onClose }: BoardProps) {
58
+ export function Board({ snapshot, workspacePath, onOpenInspector, onClose, variant = 'overlay' }: BoardProps) {
53
59
  const { theme, toggle } = useBoardTheme()
54
60
  if (snapshot === null || snapshot.root === null) return null
55
61
  // Run 16: when the synchronous workspace path differs from the async snapshot
@@ -59,25 +65,26 @@ export function Board({ snapshot, workspacePath, onOpenInspector, onClose }: Boa
59
65
  const runs = stale ? [] : listRuns(snapshot.projection)
60
66
  const headerPath = workspacePath ?? snapshot.root
61
67
  const open = (run: RecursiveRunCard) => () => onOpenInspector?.({ worktreeRoot: run.worktreeRoot, runId: run.runId })
68
+ const rootCls = variant === 'view' ? 'rec-board rec-board-view' : 'rec-board'
62
69
  if (runs.length === 0) {
63
- return createElement('div', { className: 'rec-board', 'data-theme': theme, 'data-empty': true },
70
+ return createElement('div', { className: rootCls, 'data-theme': theme, 'data-empty': true },
64
71
  createElement('header', { className: 'rec-board-header' },
65
72
  createElement('h2', { className: 'rec-board-title' }, 'Recursive runs'),
66
73
  createElement('span', { className: 'rec-board-path' }, headerPath),
67
74
  createElement('span', { className: 'rec-board-count' }, '0 runs'),
68
75
  createElement(ThemeToggle, { theme, toggle }),
69
- closeButton(onClose),
76
+ closeButton(onClose, variant),
70
77
  ),
71
78
  createElement('p', { className: 'rec-board-empty' }, 'No recursive runs in this workspace yet.'),
72
79
  )
73
80
  }
74
- return createElement('div', { className: 'rec-board', 'data-theme': theme },
81
+ return createElement('div', { className: rootCls, 'data-theme': theme },
75
82
  createElement('header', { className: 'rec-board-header' },
76
83
  createElement('h2', { className: 'rec-board-title' }, 'Recursive runs'),
77
84
  createElement('span', { className: 'rec-board-path' }, headerPath),
78
85
  createElement('span', { className: 'rec-board-count' }, runs.length + ' runs'),
79
86
  createElement(ThemeToggle, { theme, toggle }),
80
- closeButton(onClose),
87
+ closeButton(onClose, variant),
81
88
  ),
82
89
  createElement('div', { className: 'rec-columns' },
83
90
  KANBAN_LANES.map((lane) => {
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Per-phase run-doc viewer (0.2.4): renders one .recursive/run/<runId>/<file>
3
+ * markdown doc read LAZILY via the host GET /.recursive/api/doc route, inside
4
+ * the Inspector run-detail panel. Read-only: NO approve / changes / comment /
5
+ * quit actions, NO session-answer writes, NO recursive/* session events.
6
+ *
7
+ * ATTRIBUTION — ported from @guillaumemeyer/dsh-plan-approval (MIT),
8
+ * https://github.com/guillaumemeyer/dsh-plan-approval:
9
+ * - the markdown line parser (parseDoc, based on parsePlan), and
10
+ * - the vim-nav + / search key handling (onKey/move/goTop/goBottom/
11
+ * computeMatches/searchNext/searchPrev/openSearch/closeSearch).
12
+ * Reused under MIT. NO planReviewOf / overlay / session-question logic is
13
+ * carried over — this viewer reads run artifacts.
14
+ */
15
+ import { createElement, useEffect, useMemo, useRef, useState } from 'react'
16
+ import type { ReactNode } from 'react'
17
+ import { fetchPhaseDoc } from './host-api.ts'
18
+ import type { BoardTheme } from './theme.ts'
19
+
20
+ /* ============================ parser (pure) ============================ */
21
+
22
+ /** One rendered markdown line (parsePlan base + fenced code + tables). */
23
+ export interface DocLine {
24
+ kind: 'blank' | 'h1' | 'h2' | 'h3' | 'h4' | 'li' | 'plain' | 'code' | 'table';
25
+ text: string;
26
+ /** table: data rows (header separator row dropped); first row is the header. */
27
+ cells?: string[][];
28
+ }
29
+
30
+ /** Inline segment parsed from line text (bold / code span / link). */
31
+ export interface InlineSegment {
32
+ type: 'text' | 'bold' | 'code' | 'link';
33
+ text: string;
34
+ href?: string;
35
+ }
36
+
37
+ /**
38
+ * Markdown -> line tokens. Base is parsePlan (blank/h1-h4/li/plain, MIT),
39
+ * extended for fenced code blocks (one code line per block) and pipe tables
40
+ * (one table line per block, header separator row dropped).
41
+ */
42
+ export function parseDoc(plan: string): DocLine[] {
43
+ const raw = String(plan == null ? '' : plan).split('\n');
44
+ const out: DocLine[] = [];
45
+ let i = 0;
46
+ while (i < raw.length) {
47
+ const line = raw[i];
48
+ // blank
49
+ if (/^\s*$/.test(line)) { out.push({ kind: 'blank', text: '' }); i += 1; continue; }
50
+ // fenced code block: group until the closing fence
51
+ const fence = /^```\s*([\w+-]*)\s*$/.exec(line);
52
+ if (fence) {
53
+ const code: string[] = [];
54
+ i += 1;
55
+ while (i < raw.length && !/^```\s*$/.test(raw[i])) { code.push(raw[i]); i += 1; }
56
+ i += 1; // consume closing fence (or end of input)
57
+ out.push({ kind: 'code', text: code.join('\n') });
58
+ continue;
59
+ }
60
+ // pipe table: group consecutive | rows, drop the |-| separator row
61
+ if (/^\s*\|/.test(line)) {
62
+ const rows: string[][] = [];
63
+ while (i < raw.length && /^\s*\|/.test(raw[i])) {
64
+ const parts = raw[i].split('|').slice(1, -1).map((c) => c.trim());
65
+ rows.push(parts);
66
+ i += 1;
67
+ }
68
+ const data = rows.filter((r) => !r.every((c) => /^:?-{3,}:?$/.test(c)));
69
+ out.push({ kind: 'table', text: rows.map((r) => r.join(' | ')).join('\n'), cells: data });
70
+ continue;
71
+ }
72
+ // headings
73
+ const h = /^(#{1,4})\s+(.+?)\s*$/.exec(line);
74
+ if (h) {
75
+ let kind: DocLine['kind'];
76
+ switch (h[1].length) {
77
+ case 1: kind = 'h1'; break;
78
+ case 2: kind = 'h2'; break;
79
+ case 3: kind = 'h3'; break;
80
+ default: kind = 'h4';
81
+ }
82
+ out.push({ kind, text: h[2] });
83
+ i += 1;
84
+ continue;
85
+ }
86
+ // list item
87
+ const li = /^\s*[-*]\s+(.+)$/.exec(line);
88
+ if (li) { out.push({ kind: 'li', text: li[1] }); i += 1; continue; }
89
+ out.push({ kind: 'plain', text: line });
90
+ i += 1;
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /**
96
+ * Inline scanner: **bold**, `code`, [text](url); unmatched markers stay plain
97
+ * text. Used to build the React children of a line text.
98
+ */
99
+ export function parseInline(text: string): InlineSegment[] {
100
+ const out: InlineSegment[] = [];
101
+ const re = /(\*\*(.+?)\*\*)|(`([^`]+)`)|(\[([^\]]+)\]\(([^)]+)\))/g;
102
+ let last = 0;
103
+ let m: RegExpExecArray | null;
104
+ while ((m = re.exec(text)) !== null) {
105
+ if (m.index > last) out.push({ type: 'text', text: text.slice(last, m.index) });
106
+ if (m[1] !== undefined) out.push({ type: 'bold', text: m[2] });
107
+ if (m[3] !== undefined) out.push({ type: 'code', text: m[4] });
108
+ if (m[5] !== undefined) out.push({ type: 'link', text: m[6], href: m[7] });
109
+ last = re.lastIndex;
110
+ }
111
+ if (last < text.length) out.push({ type: 'text', text: text.slice(last) });
112
+ return out;
113
+ }
114
+
115
+ /* ============================ component ============================ */
116
+
117
+ export interface DocViewerProps {
118
+ runId: string;
119
+ worktreeRoot: string;
120
+ fileName: string;
121
+ theme: BoardTheme;
122
+ onClose: () => void;
123
+ }
124
+
125
+ const SEARCH_HINTS: Array<[string, string]> = [
126
+ ['j/k', 'move'], ['gg/G', 'ends'], ['/', 'search'], ['n/N', 'next/prev'], ['y', 'copy'], ['Esc', 'close'],
127
+ ];
128
+
129
+ /** Inline segments -> ReactNodes (bold/code/link markup). */
130
+ function inlineNodes(segments: InlineSegment[], baseKey: string): ReactNode[] {
131
+ return segments.map((seg, n) => {
132
+ const key = baseKey + '-seg-' + String(n);
133
+ if (seg.type === 'bold') return createElement('strong', { key }, seg.text);
134
+ if (seg.type === 'code') return createElement('code', { key, className: 'rec-doc-inline-code' }, seg.text);
135
+ if (seg.type === 'link') return createElement('a', { key, href: seg.href, target: '_blank', rel: 'noreferrer', className: 'rec-doc-inline-link' }, seg.text);
136
+ return createElement('span', { key }, seg.text);
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Render one parsed line as a React element. Headings/bullets get parsePlan
142
+ * sizing; code/table get block layout; inline markup applies to plain-ish text.
143
+ */
144
+ function lineElement(line: DocLine, i: number, isCurrent: boolean): ReactNode {
145
+ const cls = 'rec-doc-line rec-doc-' + line.kind + (isCurrent ? ' rec-doc-line-current' : '');
146
+ if (line.kind === 'blank') return createElement('div', { key: i, 'data-line': String(i), className: cls }, null);
147
+ if (line.kind === 'code') return createElement('pre', { key: i, 'data-line': String(i), className: cls + ' rec-doc-pre' }, createElement('code', { className: 'rec-doc-code' }, line.text));
148
+ if (line.kind === 'table') {
149
+ const all = line.cells ?? [];
150
+ const header = all[0] ?? [];
151
+ const body = all.slice(1);
152
+ return createElement('div', { key: i, 'data-line': String(i), className: cls },
153
+ createElement('table', { className: 'rec-doc-table' },
154
+ createElement('thead', null, createElement('tr', null, header.map((c, n) => createElement('th', { key: 'th-' + String(n) }, c)))),
155
+ createElement('tbody', null, body.map((r, n) => createElement('tr', { key: 'tr-' + String(n) }, r.map((c, m) => createElement('td', { key: 'td-' + String(m) }, c))))),
156
+ ),
157
+ );
158
+ }
159
+ const nodes = inlineNodes(parseInline(line.text), String(i));
160
+ if (line.kind === 'li') return createElement('div', { key: i, 'data-line': String(i), className: cls }, createElement('span', { className: 'rec-doc-bullet' }, '•'), createElement('span', { className: 'rec-doc-li-text' }, nodes));
161
+ return createElement('div', { key: i, 'data-line': String(i), className: cls }, nodes);
162
+ }
163
+
164
+ /**
165
+ * The per-phase doc viewer. Fetches the route on mount / fileName change.
166
+ * Vim nav + / search + n/N + Esc; y copies the doc. Esc closes search first,
167
+ * else the viewer. data-theme is passed down by the hoisting Inspector
168
+ * (0.1.10 invariant: useBoardTheme lives in the panel).
169
+ */
170
+ export function DocViewer({ runId, worktreeRoot, fileName, theme, onClose }: DocViewerProps): ReactNode {
171
+ const [text, setText] = useState<string | null>(null);
172
+ const [error, setError] = useState<string | null>(null);
173
+ const [notice, setNotice] = useState<string | null>(null);
174
+ const [cursor, setCursor] = useState(0);
175
+ const [searchOpen, setSearchOpen] = useState(false);
176
+ const [query, setQuery] = useState('');
177
+ const [matches, setMatches] = useState<number[]>([]);
178
+ const [activeMatch, setActiveMatch] = useState(0);
179
+ const ggArmed = useRef(false);
180
+ const rootRef = useRef<HTMLDivElement | null>(null);
181
+ const [copied, setCopied] = useState(false);
182
+
183
+ useEffect(() => {
184
+ let disposed = false;
185
+ setText(null); setError(null); setNotice(null); setCopied(false); setCursor(0);
186
+ fetchPhaseDoc({ root: worktreeRoot, runId, file: fileName })
187
+ .then((t) => { if (!disposed) { setText(t); setCopied(false); } })
188
+ .catch(() => { if (!disposed) setError('Failed to load doc'); });
189
+ return () => { disposed = true; };
190
+ }, [worktreeRoot, runId, fileName]);
191
+
192
+ const docLines = useMemo(() => (text === null ? [] : parseDoc(text)), [text]);
193
+
194
+ const move = (delta: number) => {
195
+ if (docLines.length === 0) return;
196
+ setCursor((c) => Math.max(0, Math.min(docLines.length - 1, c + delta)));
197
+ };
198
+ const goTop = () => setCursor(0);
199
+ const goBottom = () => setCursor(Math.max(0, docLines.length - 1));
200
+ const computeMatches = (q: string) => {
201
+ if (!q) return [];
202
+ const needle = q.toLowerCase();
203
+ const found: number[] = [];
204
+ for (let i = 0; i < docLines.length; i++) {
205
+ if (docLines[i].text.toLowerCase().indexOf(needle) >= 0) found.push(i);
206
+ }
207
+ return found;
208
+ };
209
+ const openSearch = () => { setSearchOpen(true); setError(null); setNotice(null); };
210
+ const closeSearch = () => { setSearchOpen(false); setQuery(''); setMatches([]); setActiveMatch(0); if (rootRef.current) rootRef.current.focus(); };
211
+ const onSearchChange = (e: { target: { value: string } }) => {
212
+ const q = e.target.value;
213
+ setQuery(q);
214
+ setMatches(computeMatches(q));
215
+ setActiveMatch(0);
216
+ };
217
+ const searchNext = () => {
218
+ if (matches.length === 0) return;
219
+ const next = (activeMatch + 1) % matches.length;
220
+ setActiveMatch(next);
221
+ setCursor(matches[next]);
222
+ };
223
+ const searchPrev = () => {
224
+ if (matches.length === 0) return;
225
+ const prev = (activeMatch - 1 + matches.length) % matches.length;
226
+ setActiveMatch(prev);
227
+ setCursor(matches[prev]);
228
+ };
229
+ const onSearchKey = (e: { key: string; shiftKey: boolean; preventDefault: () => void }) => {
230
+ if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); searchPrev(); }
231
+ else if (e.key === 'Enter') { e.preventDefault(); searchNext(); }
232
+ else if (e.key === 'Escape') { e.preventDefault(); closeSearch(); }
233
+ };
234
+
235
+ const copyDoc = () => {
236
+ // SAFETY: node/SSR has no clipboard object; the guarded shape matches lib.dom's
237
+ // Navigator.clipboard and falls back to an error status when absent.
238
+ const clip = (globalThis as { navigator?: { clipboard?: { writeText?: (t: string) => Promise<void> } } }).navigator?.clipboard;
239
+ if (clip === undefined || clip.writeText === undefined) { setError('Copy unavailable'); setNotice(null); return; }
240
+ if (clip && clip.writeText) {
241
+ const data = text ?? '';
242
+ clip.writeText(data).then(() => { setNotice('Doc copied'); setError(null); setCopied(true); }).catch(() => { setError('Copy failed'); setNotice(null); });
243
+ } else { setError('Copy unavailable'); setNotice(null); }
244
+ };
245
+
246
+ const onKey = (e: { key: string; preventDefault: () => void; target?: { tagName?: string } | null }) => {
247
+ const tag = (e.target && e.target.tagName) || '';
248
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return;
249
+ const rawKey = e.key || '';
250
+ const key = rawKey.toLowerCase();
251
+ if (rawKey === 'G') { e.preventDefault(); ggArmed.current = false; goBottom(); return; }
252
+ if (rawKey === 'N') { e.preventDefault(); ggArmed.current = false; searchPrev(); return; }
253
+ if (key === 'g') {
254
+ e.preventDefault();
255
+ if (ggArmed.current) { ggArmed.current = false; goTop(); }
256
+ else ggArmed.current = true;
257
+ return;
258
+ }
259
+ ggArmed.current = false;
260
+ if (key === 'j') { e.preventDefault(); move(1); }
261
+ else if (key === 'k') { e.preventDefault(); move(-1); }
262
+ else if (key === '/') { e.preventDefault(); openSearch(); }
263
+ else if (key === 'n') { e.preventDefault(); searchNext(); }
264
+ else if (key === 'y') { e.preventDefault(); copyDoc(); }
265
+ else if (key === 'escape') {
266
+ e.preventDefault();
267
+ if (searchOpen) closeSearch();
268
+ else onClose();
269
+ }
270
+ };
271
+
272
+ const matchSet = new Set(matches);
273
+ const activeLine = matches.length > 0 ? matches[activeMatch] : -1;
274
+
275
+ const lineEls = docLines.map((line, i) => lineElement(line, i, i === cursor));
276
+
277
+ const searchBar = searchOpen ? createElement('div', { className: 'rec-doc-search' },
278
+ createElement('input', { className: 'rec-doc-search-input', value: query, placeholder: '/ search doc…', onChange: onSearchChange, onKeyDown: onSearchKey }),
279
+ createElement('span', { className: 'rec-doc-search-count' }, matches.length > 0 ? (activeMatch + 1) + '/' + matches.length : (query ? '0' : '')),
280
+ ) : null;
281
+
282
+ const statusText = error || notice || (docLines.length + ' lines');
283
+ const statusCls = 'rec-doc-status' + (error ? ' rec-doc-status-error' : notice ? ' rec-doc-status-ok' : '');
284
+
285
+ return createElement('div', { className: 'rec-doc', 'data-theme': theme, onKeyDown: onKey, ref: rootRef, tabIndex: -1 },
286
+ createElement('header', { className: 'rec-doc-header' },
287
+ createElement('div', { className: 'rec-doc-heading' },
288
+ createElement('span', { className: 'rec-doc-badge' }, 'Phase doc'),
289
+ createElement('h2', { className: 'rec-doc-title' }, fileName),
290
+ createElement('span', { className: 'rec-doc-run' }, runId),
291
+ ),
292
+ createElement('button', { type: 'button', className: 'rec-doc-btn rec-doc-copy', onClick: copyDoc, title: 'Copy doc', 'aria-label': 'Copy doc' }, 'Copy doc'),
293
+ createElement('button', { type: 'button', className: 'rec-doc-btn rec-doc-close', onClick: onClose, title: 'Close', 'aria-label': 'Close' }, 'Close'),
294
+ ),
295
+ searchBar,
296
+ createElement('div', { className: 'rec-doc-body' },
297
+ text === null && error === null ? createElement('p', { className: 'rec-doc-text' }, 'Loading doc…') : null,
298
+ error !== null ? createElement('p', { className: 'rec-doc-text rec-doc-error' }, error) : null,
299
+ text !== null ? lineEls : null,
300
+ ),
301
+ createElement('footer', { className: 'rec-doc-footer' },
302
+ createElement('div', { className: statusCls, role: 'status' }, statusText),
303
+ createElement('div', { className: 'rec-doc-hints' }, SEARCH_HINTS.map((h, n) => createElement('span', { key: 'hint-' + String(n) }, createElement('kbd', null, h[0]), ' ' + h[1] + (n < SEARCH_HINTS.length - 1 ? ' |' : '')))),
304
+ ),
305
+ );
306
+ }
@@ -55,6 +55,34 @@ export async function fetchLiveState(scope: LiveScope, signal?: AbortSignal): Pr
55
55
  }
56
56
  }
57
57
 
58
+ /** One doc fetch request: the host-known workspace root + run id + phase doc file. */
59
+ export interface PhaseDocRequest {
60
+ root: string
61
+ runId: string
62
+ file: string
63
+ }
64
+
65
+ /**
66
+ * Fetch one per-phase run doc (0.2.4) via the lazy GET /.recursive/api/doc
67
+ * route. Raw markdown text; the host validates the root (known workspace) and
68
+ * guards the file path (phase-doc basename only, containment under the run
69
+ * dir). Same 15s AbortController timeout as fetchLiveState.
70
+ */
71
+ export async function fetchPhaseDoc(req: PhaseDocRequest): Promise<string> {
72
+ const controller = new AbortController()
73
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
74
+ try {
75
+ const q = 'root=' + encodeURIComponent(req.root)
76
+ + '&runId=' + encodeURIComponent(req.runId)
77
+ + '&file=' + encodeURIComponent(req.file)
78
+ const res = await fetch(RECURSIVE_API_PREFIX + '/doc?' + q, { signal: controller.signal, cache: 'no-store' })
79
+ if (!res.ok) throw new Error('recursive phase doc: HTTP ' + res.status)
80
+ return await res.text()
81
+ } finally {
82
+ clearTimeout(timer)
83
+ }
84
+ }
85
+
58
86
  /**
59
87
  * Subscribe to the SSE events feed. Returns a disposer. The host pushes a full
60
88
  * {revision, root, projection} frame on every fs change + a 15s heartbeat.
@@ -17,6 +17,8 @@ import { claimClientApply, releaseClientApply } from './apply-guard.ts'
17
17
 
18
18
  export { Board, listRuns } from './board.tsx'
19
19
  export { Inspector } from './inspector.tsx'
20
+ export { DocViewer, parseDoc, parseInline } from './doc-viewer.tsx'
21
+ export { RecursiveView } from './slots.ts'
20
22
  export { RecursiveSettings } from './settings.tsx'
21
23
  export { useLiveProjection } from './use-live.ts'
22
24
  export type { LiveProjectionSnapshot } from './use-live.ts'