@crouton-kit/humanloop 0.1.4 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,271 @@
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import stringWidth from 'string-width';
6
+ import { TERMRENDER_VERSION } from './version.js';
7
+ // ── The sole org-wide termrender binding ─────────────────────────────────────
8
+ //
9
+ // termrender is a humanloop-managed dependency: a pure-Python tool pinned to
10
+ // TERMRENDER_VERSION, installed into a venv humanloop owns. The binary is
11
+ // resolved by ABSOLUTE PATH inside that venv — never `$PATH` — so a user's
12
+ // own `pip install termrender` can never shadow or break the pin.
13
+ function findPkgRoot() {
14
+ let dir = dirname(fileURLToPath(import.meta.url));
15
+ for (let i = 0; i < 12; i++) {
16
+ if (existsSync(join(dir, 'package.json')))
17
+ return dir;
18
+ const parent = dirname(dir);
19
+ if (parent === dir)
20
+ break;
21
+ dir = parent;
22
+ }
23
+ // dist/render/termrender.js or src/render/termrender.ts → two up is pkgRoot.
24
+ return resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
25
+ }
26
+ const PKG_ROOT = findPkgRoot();
27
+ const VENV_DIR = resolve(PKG_ROOT, '.venv');
28
+ const VENV_BIN = resolve(PKG_ROOT, '.venv/bin/termrender');
29
+ const VENV_PYTHON = resolve(PKG_ROOT, '.venv/bin/python');
30
+ let rendererState = 'unchecked';
31
+ function binaryOk() {
32
+ if (!existsSync(VENV_BIN))
33
+ return false;
34
+ try {
35
+ // v2 contract: no --version flag; use -h (exit 0) as a liveness check.
36
+ execFileSync(VENV_BIN, ['-h'], {
37
+ encoding: 'utf8',
38
+ stdio: ['ignore', 'pipe', 'pipe'],
39
+ timeout: 5000,
40
+ });
41
+ return true;
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ function uvAvailable() {
48
+ try {
49
+ execFileSync('uv', ['--version'], { stdio: 'pipe', timeout: 5000 });
50
+ return true;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ /**
57
+ * Memoized, self-healing. Ensures the pinned termrender binary exists inside
58
+ * the humanloop-managed venv; (re)provisions it via `uv` when missing or the
59
+ * version drifts from the pin. Runs at most once per process. Single
60
+ * degradation path: `uv` absent → one stderr remediation line + plaintext
61
+ * fallback. win32 → plaintext (no renderer).
62
+ *
63
+ * Invoked at postinstall AND lazily on the first render/check/display call,
64
+ * so `npm ci --ignore-scripts` consumers still self-heal on first use.
65
+ */
66
+ export function ensureRenderer() {
67
+ if (rendererState !== 'unchecked')
68
+ return;
69
+ if (process.platform === 'win32') {
70
+ rendererState = 'unavailable';
71
+ return;
72
+ }
73
+ if (binaryOk()) {
74
+ rendererState = 'ready';
75
+ return;
76
+ }
77
+ if (!uvAvailable()) {
78
+ process.stderr.write('[hl] termrender unavailable — install uv to enable rich rendering:\n' +
79
+ ' curl -LsSf https://astral.sh/uv/install.sh | sh\n');
80
+ rendererState = 'unavailable';
81
+ return;
82
+ }
83
+ try {
84
+ execFileSync('uv', ['venv', VENV_DIR], { stdio: 'pipe', timeout: 60000 });
85
+ execFileSync('uv', ['pip', 'install', '--python', VENV_PYTHON, `termrender==${TERMRENDER_VERSION}`], { stdio: 'pipe', timeout: 120000 });
86
+ }
87
+ catch (err) {
88
+ process.stderr.write(`[hl] termrender install failed (${err instanceof Error ? err.message : String(err)}); using plaintext fallback\n`);
89
+ rendererState = 'unavailable';
90
+ return;
91
+ }
92
+ rendererState = binaryOk() ? 'ready' : 'unavailable';
93
+ if (rendererState === 'unavailable') {
94
+ process.stderr.write('[hl] termrender install completed but health check failed; using plaintext fallback\n');
95
+ }
96
+ }
97
+ /** Cheap predicate — true when the pinned managed binary is present and correct. Does not install. */
98
+ export function isRendererReady() {
99
+ if (rendererState === 'ready')
100
+ return true;
101
+ if (rendererState === 'unavailable')
102
+ return false;
103
+ return process.platform !== 'win32' && binaryOk();
104
+ }
105
+ // ── Plaintext fallback helpers (kept here so this is the only termrender site) ─
106
+ const CONTROL_CHARS_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b[@-_]|[\x00-\x08\x0B\x0E-\x1F\x7F-\x9F]/g;
107
+ function sanitize(text) {
108
+ if (typeof text !== 'string')
109
+ return '';
110
+ return text.replace(CONTROL_CHARS_RE, '');
111
+ }
112
+ function sliceByWidth(s, maxWidth) {
113
+ let w = 0;
114
+ let out = '';
115
+ for (const ch of s) {
116
+ const cw = stringWidth(ch);
117
+ if (w + cw > maxWidth)
118
+ break;
119
+ out += ch;
120
+ w += cw;
121
+ }
122
+ if (out === '' && s.length > 0)
123
+ out = [...s][0];
124
+ return out;
125
+ }
126
+ function wrap(text, maxWidth) {
127
+ if (maxWidth < 1)
128
+ return [text];
129
+ const out = [];
130
+ const paragraphs = text.split('\n');
131
+ for (let p = 0; p < paragraphs.length; p++) {
132
+ const para = paragraphs[p];
133
+ if (para === '') {
134
+ out.push('');
135
+ continue;
136
+ }
137
+ const words = para.split(/[ \t]+/).filter(Boolean);
138
+ let current = '';
139
+ for (let word of words) {
140
+ while (stringWidth(word) > maxWidth) {
141
+ if (current) {
142
+ out.push(current);
143
+ current = '';
144
+ }
145
+ const piece = sliceByWidth(word, maxWidth);
146
+ out.push(piece);
147
+ word = word.slice(piece.length);
148
+ }
149
+ const candidate = current ? `${current} ${word}` : word;
150
+ if (stringWidth(candidate) <= maxWidth) {
151
+ current = candidate;
152
+ }
153
+ else {
154
+ if (current)
155
+ out.push(current);
156
+ current = word;
157
+ }
158
+ }
159
+ if (current)
160
+ out.push(current);
161
+ }
162
+ return out.length > 0 ? out : [''];
163
+ }
164
+ // ── Render surface ───────────────────────────────────────────────────────────
165
+ const _bodyCache = new Map();
166
+ /** Render markdown to terminal lines via the pinned binary; plaintext fallback. */
167
+ export function renderMarkdown(md, width) {
168
+ const key = `${md}\0${width}`;
169
+ const cached = _bodyCache.get(key);
170
+ if (cached)
171
+ return cached;
172
+ ensureRenderer();
173
+ if (rendererState === 'ready') {
174
+ try {
175
+ const input = JSON.stringify({ source: md, width, color: true });
176
+ const out = execFileSync(VENV_BIN, ['doc', 'render'], {
177
+ input,
178
+ encoding: 'utf-8',
179
+ timeout: 5000,
180
+ stdio: ['pipe', 'pipe', 'pipe'],
181
+ });
182
+ const lines = out.split('\n');
183
+ if (lines.length > 0 && lines[lines.length - 1] === '')
184
+ lines.pop();
185
+ _bodyCache.set(key, lines);
186
+ return lines;
187
+ }
188
+ catch {
189
+ /* fall through to plaintext */
190
+ }
191
+ }
192
+ const fallback = wrap(sanitize(md), width);
193
+ _bodyCache.set(key, fallback);
194
+ return fallback;
195
+ }
196
+ /** Validate markdown via `termrender doc check`. */
197
+ export function checkMarkdown(md) {
198
+ ensureRenderer();
199
+ // Renderer unavailable → don't block validation; the body just renders as
200
+ // plaintext later. Bricking deck validation here would be the wrong default.
201
+ if (rendererState !== 'ready')
202
+ return { ok: true };
203
+ const input = JSON.stringify({ source: md });
204
+ const result = spawnSync(VENV_BIN, ['doc', 'check'], {
205
+ input,
206
+ encoding: 'utf-8',
207
+ timeout: 5000,
208
+ });
209
+ if (result.error) {
210
+ return { ok: false, error: `termrender: invocation failed: ${result.error.message}` };
211
+ }
212
+ let parsed = null;
213
+ const rawStdout = typeof result.stdout === 'string' ? result.stdout : '';
214
+ if (rawStdout) {
215
+ try {
216
+ parsed = JSON.parse(rawStdout.trim());
217
+ }
218
+ catch {
219
+ // stdout not parseable — fall through to exit-code handling
220
+ }
221
+ }
222
+ if (parsed !== null) {
223
+ if (parsed.ok)
224
+ return { ok: true };
225
+ const first = Array.isArray(parsed.errors) ? parsed.errors[0] : undefined;
226
+ const msg = (first && typeof first.message === 'string' && first.message) ? first.message : 'invalid markdown';
227
+ return { ok: false, error: `termrender: ${msg}` };
228
+ }
229
+ // exit code 2 = invalid per contract; any non-zero is an error
230
+ if (result.status !== 0) {
231
+ return { ok: false, error: `termrender: doc check exited ${result.status}` };
232
+ }
233
+ return { ok: true };
234
+ }
235
+ /**
236
+ * Spawn termrender into a live tmux pane. The pane-budget policy (whether to
237
+ * split vs open a new window) is decided by the caller (`src/surfaces/
238
+ * display.ts`); this is the thin managed-binary spawn it delegates to.
239
+ */
240
+ export function displayInPane(path, opts = {}) {
241
+ ensureRenderer();
242
+ if (rendererState !== 'ready')
243
+ return {};
244
+ const input = JSON.stringify({
245
+ path,
246
+ watch: opts.watch !== false,
247
+ window: opts.newWindow ? 'new' : 'split',
248
+ });
249
+ const result = spawnSync(VENV_BIN, ['pane', 'open'], {
250
+ input,
251
+ encoding: 'utf-8',
252
+ stdio: ['pipe', 'pipe', 'pipe'],
253
+ });
254
+ if (result.error || result.status !== 0)
255
+ return {};
256
+ // `encoding: 'utf-8'` makes spawnSync return stdout as a string.
257
+ const rawStdout = result.stdout;
258
+ if (!rawStdout)
259
+ return {};
260
+ let parsed = null;
261
+ try {
262
+ parsed = JSON.parse(rawStdout.trim());
263
+ }
264
+ catch {
265
+ return {};
266
+ }
267
+ if (parsed && typeof parsed.pane_id === 'string' && parsed.pane_id) {
268
+ return { paneId: parsed.pane_id };
269
+ }
270
+ return {};
271
+ }
@@ -0,0 +1 @@
1
+ export declare const TERMRENDER_VERSION = "2.1.0";
@@ -0,0 +1 @@
1
+ export const TERMRENDER_VERSION = '2.1.0';
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ import { ensureRenderer, isRendererReady } from '../render/termrender.js';
3
+ // postinstall hook. Best-effort: provision the pinned termrender venv now so
4
+ // the first render is fast. NEVER fail — a renderer hiccup must not brick a
5
+ // consumer's `npm install`. The lazy ensureRenderer() on first render covers
6
+ // the `npm ci --ignore-scripts` case.
7
+ try {
8
+ ensureRenderer();
9
+ if (!isRendererReady()) {
10
+ process.stderr.write('[hl] termrender not provisioned at install time; will retry lazily on first render.\n');
11
+ }
12
+ }
13
+ catch (err) {
14
+ process.stderr.write(`[hl] termrender postinstall skipped: ${err instanceof Error ? err.message : String(err)}\n`);
15
+ }
16
+ process.exit(0);
@@ -0,0 +1,5 @@
1
+ import type { DisplayOpts } from '../types.js';
2
+ export declare function countPanesInCurrentWindow(): number;
3
+ export declare function display(path: string, opts?: DisplayOpts): {
4
+ paneId?: string;
5
+ };
@@ -0,0 +1,19 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { displayInPane } from '../render/termrender.js';
3
+ export function countPanesInCurrentWindow() {
4
+ // -t '' targets the current window of the current session.
5
+ const result = spawnSync('tmux', ['list-panes', '-F', '#{pane_id}'], {
6
+ encoding: 'utf8',
7
+ });
8
+ if (result.status !== 0)
9
+ return 0;
10
+ return result.stdout.split('\n').filter((line) => line.trim() !== '').length;
11
+ }
12
+ export function display(path, opts) {
13
+ const watch = opts?.watch !== false;
14
+ const window = (opts?.window === 'split' || opts?.window === 'new') ? opts.window : 'auto';
15
+ const maxPanes = (opts?.maxPanes !== undefined && opts.maxPanes > 0) ? opts.maxPanes : 3;
16
+ const newWindow = window === 'new' ||
17
+ (window === 'auto' && countPanesInCurrentWindow() >= maxPanes);
18
+ return displayInPane(path, { watch, newWindow });
19
+ }
package/dist/tui/app.d.ts CHANGED
@@ -1,6 +1,29 @@
1
- import type { Deck, InteractionResponse, MountedPanel, MountedPanelOpts } from '../types.js';
1
+ import type { Deck, InteractionResponse, MountedPanel, MountedPanelOpts, GenerateVisual } from '../types.js';
2
+ /** Validate an arbitrary parsed value as a Deck. Delegates to the canonical
3
+ * Zod validator in `inbox/deck-schema.ts` (the single source of truth shared
4
+ * with sisyphus). Kept exported for back-compat. */
2
5
  export declare function validateInput(parsed: unknown): Deck;
3
6
  export declare function mountPanel(opts: MountedPanelOpts): MountedPanel;
7
+ export interface ResolveDirOpts {
8
+ /** Claude session id → per-interaction visual context from history. */
9
+ sessionId?: string;
10
+ /** Explicit visual generator; overrides the sessionId default. */
11
+ generateVisual?: GenerateVisual;
12
+ cols?: number;
13
+ rows?: number;
14
+ }
15
+ /**
16
+ * Resolve an interaction directory in place: mount the panel TUI keyed off
17
+ * `<dir>/progress.json`, and on finish (full completion OR human-finished
18
+ * with skips) write `<dir>/response.json` atomically and drop the progress
19
+ * file. A hard process kill leaves `progress.json` for a later resume —
20
+ * `tryResume` (unchanged logic) reads the new dir-derived path.
21
+ */
22
+ export declare function resolveInteractionDir(dir: string, deck: Deck, opts?: ResolveDirOpts): Promise<{
23
+ responses: InteractionResponse[];
24
+ completedAt: string;
25
+ responsePath: string;
26
+ }>;
4
27
  export declare function launchTui(decisionsPath: string, sessionId?: string): Promise<{
5
28
  responses: InteractionResponse[];
6
29
  completedAt: string;
package/dist/tui/app.js CHANGED
@@ -1,105 +1,17 @@
1
1
  import { readFileSync, existsSync, writeFileSync, renameSync, unlinkSync } from 'fs';
2
+ import { dirname, resolve as resolvePath } from 'node:path';
2
3
  import { setupTerminal, restoreTerminal, parseKeypress, getTerminalSize } from './terminal.js';
3
4
  import { diffFrame, renderOverview, renderItemReview, renderFinal } from './render.js';
4
5
  import { handleKeypress, assignShortcuts } from './input.js';
5
6
  import { readConversation } from '../conversation/reader.js';
6
7
  import { defaultGenerateVisual } from '../visuals/generate.js';
8
+ import { validateDeck } from '../inbox/deck-schema.js';
9
+ import { progressPath as progressPathFor, writeResponse, clearProgress } from '../inbox/convention.js';
10
+ /** Validate an arbitrary parsed value as a Deck. Delegates to the canonical
11
+ * Zod validator in `inbox/deck-schema.ts` (the single source of truth shared
12
+ * with sisyphus). Kept exported for back-compat. */
7
13
  export function validateInput(parsed) {
8
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
9
- throw new Error('Deck file must be a JSON object with an `interactions` array');
10
- }
11
- const obj = parsed;
12
- if (!Array.isArray(obj.interactions)) {
13
- throw new Error('`interactions` must be an array');
14
- }
15
- if (obj.interactions.length === 0) {
16
- throw new Error('No interactions in deck file');
17
- }
18
- if (obj.title !== undefined && typeof obj.title !== 'string') {
19
- throw new Error('`title` must be a string when present');
20
- }
21
- const seen = new Set();
22
- const validated = [];
23
- for (let i = 0; i < obj.interactions.length; i++) {
24
- const raw = obj.interactions[i];
25
- const where = `interactions[${i}]`;
26
- if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
27
- throw new Error(`${where} must be an object`);
28
- }
29
- if (typeof raw.id !== 'string' || raw.id === '') {
30
- throw new Error(`${where}.id must be a non-empty string`);
31
- }
32
- if (seen.has(raw.id)) {
33
- throw new Error(`Duplicate interaction id: ${JSON.stringify(raw.id)}`);
34
- }
35
- seen.add(raw.id);
36
- if (typeof raw.title !== 'string' || raw.title === '') {
37
- throw new Error(`${where}.title must be a non-empty string`);
38
- }
39
- if (!Array.isArray(raw.options)) {
40
- throw new Error(`${where}.options must be an array`);
41
- }
42
- const opts = [];
43
- for (let j = 0; j < raw.options.length; j++) {
44
- const o = raw.options[j];
45
- const owhere = `${where}.options[${j}]`;
46
- if (typeof o !== 'object' || o === null || Array.isArray(o)) {
47
- throw new Error(`${owhere} must be an object`);
48
- }
49
- if (typeof o.id !== 'string' || o.id === '') {
50
- throw new Error(`${owhere}.id must be a non-empty string`);
51
- }
52
- if (typeof o.label !== 'string') {
53
- throw new Error(`${owhere}.label must be a string`);
54
- }
55
- const opt = { id: o.id, label: o.label };
56
- if (o.description !== undefined) {
57
- if (typeof o.description !== 'string')
58
- throw new Error(`${owhere}.description must be a string`);
59
- opt.description = o.description;
60
- }
61
- if (o.shortcut !== undefined) {
62
- if (typeof o.shortcut !== 'string')
63
- throw new Error(`${owhere}.shortcut must be a string`);
64
- opt.shortcut = o.shortcut;
65
- }
66
- opts.push(opt);
67
- }
68
- const interaction = { id: raw.id, title: raw.title, options: opts };
69
- if (raw.subtitle !== undefined) {
70
- if (typeof raw.subtitle !== 'string')
71
- throw new Error(`${where}.subtitle must be a string`);
72
- interaction.subtitle = raw.subtitle;
73
- }
74
- if (raw.body !== undefined) {
75
- if (typeof raw.body !== 'string')
76
- throw new Error(`${where}.body must be a string`);
77
- interaction.body = raw.body;
78
- }
79
- if (raw.bodyPath !== undefined) {
80
- if (typeof raw.bodyPath !== 'string')
81
- throw new Error(`${where}.bodyPath must be a string`);
82
- interaction.bodyPath = raw.bodyPath;
83
- }
84
- if (raw.freetextLabel !== undefined) {
85
- if (typeof raw.freetextLabel !== 'string')
86
- throw new Error(`${where}.freetextLabel must be a string`);
87
- interaction.freetextLabel = raw.freetextLabel;
88
- }
89
- if (raw.allowFreetext !== undefined) {
90
- if (typeof raw.allowFreetext !== 'boolean')
91
- throw new Error(`${where}.allowFreetext must be a boolean`);
92
- interaction.allowFreetext = raw.allowFreetext;
93
- }
94
- if (raw.kind !== undefined) {
95
- interaction.kind = raw.kind;
96
- }
97
- validated.push(interaction);
98
- }
99
- const deck = { interactions: validated };
100
- if (obj.title !== undefined)
101
- deck.title = obj.title;
102
- return deck;
14
+ return validateDeck(parsed);
103
15
  }
104
16
  // ── Internal helpers ──────────────────────────────────────────────────────────
105
17
  function buildInitialState(deck) {
@@ -269,17 +181,18 @@ export function mountPanel(opts) {
269
181
  },
270
182
  };
271
183
  }
272
- // ── launchTui shim ────────────────────────────────────────────────────────────
273
- export async function launchTui(decisionsPath, sessionId) {
274
- if (!existsSync(decisionsPath)) {
275
- throw new Error(`Decisions file not found: ${decisionsPath}`);
276
- }
277
- const raw = readFileSync(decisionsPath, 'utf8');
278
- const deck = validateInput(JSON.parse(raw));
184
+ /**
185
+ * Resolve an interaction directory in place: mount the panel TUI keyed off
186
+ * `<dir>/progress.json`, and on finish (full completion OR human-finished
187
+ * with skips) write `<dir>/response.json` atomically and drop the progress
188
+ * file. A hard process kill leaves `progress.json` for a later resume —
189
+ * `tryResume` (unchanged logic) reads the new dir-derived path.
190
+ */
191
+ export async function resolveInteractionDir(dir, deck, opts = {}) {
279
192
  let conversationContext = '';
280
- if (sessionId !== undefined) {
193
+ if (opts.sessionId !== undefined) {
281
194
  try {
282
- const conv = readConversation(sessionId);
195
+ const conv = readConversation(opts.sessionId);
283
196
  conversationContext = conv.map((m) => `${m.role}: ${m.content}`).join('\n\n');
284
197
  }
285
198
  catch {
@@ -287,7 +200,13 @@ export async function launchTui(decisionsPath, sessionId) {
287
200
  }
288
201
  }
289
202
  setupTerminal();
290
- const { cols, rows } = getTerminalSize();
203
+ const term = getTerminalSize();
204
+ const cols = opts.cols ?? term.cols;
205
+ const rows = opts.rows ?? term.rows;
206
+ const generateVisual = opts.generateVisual ??
207
+ (opts.sessionId !== undefined
208
+ ? (interaction) => defaultGenerateVisual(interaction, conversationContext)
209
+ : undefined);
291
210
  return new Promise((resolve) => {
292
211
  let panel = null;
293
212
  let prevFrameLocal = [];
@@ -302,28 +221,30 @@ export async function launchTui(decisionsPath, sessionId) {
302
221
  process.stdout.write('\x1b[?2026l');
303
222
  prevFrameLocal = nextPrevFrame;
304
223
  };
305
- const onComplete = (responses) => {
224
+ const finalize = (responses) => {
306
225
  restoreTerminal();
307
226
  process.stdin.removeListener('data', onData);
308
227
  panel?.unmount();
309
- resolve({ responses, completedAt: new Date().toISOString() });
228
+ const completedAt = new Date().toISOString();
229
+ // Resolved supersedes in-progress: write response.json, drop progress.json.
230
+ const rp = writeResponse(dir, responses, completedAt);
231
+ clearProgress(dir);
232
+ resolve({ responses, completedAt, responsePath: rp });
310
233
  };
311
234
  panel = mountPanel({
312
235
  deck,
313
- progressPath: `${decisionsPath}.progress.json`,
236
+ progressPath: progressPathFor(dir),
314
237
  cols,
315
238
  rows,
316
- generateVisual: sessionId !== undefined
317
- ? (interaction) => defaultGenerateVisual(interaction, conversationContext)
318
- : undefined,
239
+ generateVisual,
319
240
  onProgress: (responses) => {
320
241
  lastResponses = responses;
321
242
  if (panel !== null)
322
243
  flushHost(panel.render());
323
244
  },
324
- onComplete,
245
+ onComplete: finalize,
325
246
  onExit: () => {
326
- onComplete(lastResponses);
247
+ finalize(lastResponses);
327
248
  },
328
249
  });
329
250
  flushHost(panel.render());
@@ -335,3 +256,17 @@ export async function launchTui(decisionsPath, sessionId) {
335
256
  process.stdin.on('data', onData);
336
257
  });
337
258
  }
259
+ // ── launchTui — file-path entry over the dir resolver (a kept public export
260
+ // per the interaction-layer plan; consumed until consumers move to ask()) ──
261
+ export async function launchTui(decisionsPath, sessionId) {
262
+ if (!existsSync(decisionsPath)) {
263
+ throw new Error(`Decisions file not found: ${decisionsPath}`);
264
+ }
265
+ const raw = readFileSync(decisionsPath, 'utf8');
266
+ const deck = validateInput(JSON.parse(raw));
267
+ // The interaction dir is the deck file's directory; progress/response live
268
+ // there per the convention.
269
+ const dir = dirname(resolvePath(decisionsPath));
270
+ const { responses, completedAt } = await resolveInteractionDir(dir, deck, { sessionId });
271
+ return { responses, completedAt };
272
+ }