@crouton-kit/humanloop 0.3.29 → 0.3.31

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/dist/api.js CHANGED
@@ -5,6 +5,7 @@ import { resolveInteractionDir } from './tui/app.js';
5
5
  import { scanInbox } from './inbox/scan.js';
6
6
  import { pickFromInbox } from './inbox/tui.js';
7
7
  import { deckPath, atomicWriteJson, readJson, stampCanvasNode } from './inbox/convention.js';
8
+ import { resolveDeckBodyPaths } from './inbox/deck-schema.js';
8
9
  import { getTerminalSize } from './tui/terminal.js';
9
10
  import { notifyDeck } from './inbox/deck-factories.js';
10
11
  import { buildSummary } from './summary.js';
@@ -20,6 +21,11 @@ function managedDir() {
20
21
  export async function ask(deck, opts = {}) {
21
22
  const dir = opts.dir ?? managedDir();
22
23
  mkdirSync(dir, { recursive: true });
24
+ // Canonical bodyPath → body normalization boundary (see deck-schema.ts) —
25
+ // resolved BEFORE the deck is ever written to disk, so every reader
26
+ // downstream (terminal render, the live-reload poller, the browser server)
27
+ // only ever sees a plain `body`.
28
+ deck = resolveDeckBodyPaths(deck, dir);
23
29
  stampCanvasNode(deck);
24
30
  atomicWriteJson(deckPath(dir), deck);
25
31
  const { responses, completedAt, responsePath, deck: answeredDeck } = await resolveInteractionDir(dir, deck, {
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Best-effort open a URL in the user's default browser: `open` on macOS,
3
+ * `xdg-open` on Linux. Never throws and never blocks the handoff — an
4
+ * unsupported platform or a missing binary just prints the URL so the human
5
+ * can open it by hand.
6
+ */
7
+ export declare function openBrowser(url: string): void;
@@ -0,0 +1,26 @@
1
+ import { spawn } from 'node:child_process';
2
+ /**
3
+ * Best-effort open a URL in the user's default browser: `open` on macOS,
4
+ * `xdg-open` on Linux. Never throws and never blocks the handoff — an
5
+ * unsupported platform or a missing binary just prints the URL so the human
6
+ * can open it by hand.
7
+ */
8
+ export function openBrowser(url) {
9
+ const bin = process.platform === 'darwin' ? 'open'
10
+ : process.platform === 'linux' ? 'xdg-open'
11
+ : null;
12
+ if (bin === null) {
13
+ process.stderr.write(`humanloop: open this URL in a browser: ${url}\n`);
14
+ return;
15
+ }
16
+ try {
17
+ const child = spawn(bin, [url], { stdio: 'ignore', detached: true });
18
+ child.on('error', () => {
19
+ process.stderr.write(`humanloop: could not launch "${bin}" — open this URL manually: ${url}\n`);
20
+ });
21
+ child.unref();
22
+ }
23
+ catch {
24
+ process.stderr.write(`humanloop: open this URL in a browser: ${url}\n`);
25
+ }
26
+ }
@@ -0,0 +1,62 @@
1
+ import type { Deck, FeedbackResult, InteractionResponse } from '../types.js';
2
+ export interface WebServerOpts {
3
+ /** Interaction dir — the deck.json / response.json / progress.json convention. */
4
+ dir: string;
5
+ /** The deck to serve. Re-read from disk on every GET in case it changed. */
6
+ deck: Deck;
7
+ /**
8
+ * Fired once the browser's submit has been written to response.json —
9
+ * exactly once per server instance, only for the first accepted submit
10
+ * (later submits get a 409 and never re-fire this). `responsePath`/
11
+ * `completedAt` are already persisted to disk — the caller converges the
12
+ * host surface, it must NOT write the result again. Fired only after the
13
+ * submit's own HTTP 200 has finished flushing to its socket, so it is
14
+ * safe for this callback to `stop()` the server (which force-closes all
15
+ * open sockets) without racing the ack the browser is waiting on.
16
+ */
17
+ onSubmit?: (responses: InteractionResponse[], completedAt: string, responsePath: string) => void;
18
+ }
19
+ export interface ReviewWebServerOpts {
20
+ /** Shared review job dir — carries review.vim and feedback.json. */
21
+ jobDir: string;
22
+ /** Absolute source file under review. */
23
+ file: string;
24
+ /** Absolute feedback JSON output path. */
25
+ output: string;
26
+ /** Optional submit sentinel written on final browser submit. */
27
+ submitFlagPath?: string;
28
+ /** Fired after the HTTP submit ack has finished flushing. */
29
+ onSubmit?: (result: FeedbackResult, submittedAt: string, outputPath: string) => void;
30
+ /** Test-only override for how long `requestTakeBack()` waits for open tabs
31
+ * to ack a flush before giving up and broadcasting `taken-back` anyway.
32
+ * Defaults to 2000ms in real usage. */
33
+ takeBackAckTimeoutMs?: number;
34
+ }
35
+ export interface WebServerHandle {
36
+ /** `http://127.0.0.1:<port>/` — open this in a browser. */
37
+ url: string;
38
+ port: number;
39
+ /** Mark this server as the active editing authority — until called, the
40
+ * write endpoints (review draft/submit) 409 with `not_handed_off`. Deck's
41
+ * implementation is a documented no-op: a deck server is only ever created
42
+ * post-handoff (see `enterHandoff` in tui/app.ts), so it has no separate
43
+ * activation moment to gate. */
44
+ activate(): void;
45
+ /** Ask every connected browser tab to flush any pending edit and ack, then
46
+ * broadcast `{type:'taken-back'}`. Bounded by a timeout so a tab that never
47
+ * acks (closed, network hiccup) can't hang take-back forever. Safe to call
48
+ * with zero open sockets (e.g. the browser was never opened). */
49
+ requestTakeBack(): Promise<void>;
50
+ /** Stop listening, close all sockets (WS and HTTP), and tear down. */
51
+ stop(): Promise<void>;
52
+ }
53
+ /**
54
+ * Start an on-demand local HTTP+WS server over one interaction dir. Binds to
55
+ * an ephemeral port on 127.0.0.1 only (never 0.0.0.0 — this is a same-machine
56
+ * handoff, not a shareable link). The caller owns the lifecycle: start it
57
+ * when the human hands off to the browser, `stop()` it on take-back or once
58
+ * `onSubmit` fires.
59
+ */
60
+ export declare function startWebServer(opts: WebServerOpts): Promise<WebServerHandle>;
61
+ /** Start a review-mode browser server over one markdown review job. */
62
+ export declare function startReviewWebServer(opts: ReviewWebServerOpts): Promise<WebServerHandle>;
@@ -0,0 +1,440 @@
1
+ import { createServer } from 'node:http';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { join, extname, dirname, basename } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { WebSocketServer } from 'ws';
7
+ import { deckPath, readJson, writeResponse, clearProgress } from '../inbox/convention.js';
8
+ import { buildDraftFeedbackResult, buildFinalFeedbackResult, parseFeedbackComments, readStoredDraftFeedbackResult, serializeFeedbackResult, writeFeedbackResult, writeSubmitFlag, } from '../editor/feedback.js';
9
+ // See $CRTR_CONTEXT_DIR/phase1-server-contract.md for the deck HTTP/WS API this
10
+ // module implements — this file is the reference implementation for the deck
11
+ // surface, and the review surface reuses the same static/WS skeleton.
12
+ const MIME = {
13
+ '.html': 'text/html; charset=utf-8',
14
+ '.js': 'text/javascript; charset=utf-8',
15
+ '.mjs': 'text/javascript; charset=utf-8',
16
+ '.css': 'text/css; charset=utf-8',
17
+ '.json': 'application/json; charset=utf-8',
18
+ '.svg': 'image/svg+xml',
19
+ '.png': 'image/png',
20
+ '.ico': 'image/x-icon',
21
+ '.woff': 'font/woff',
22
+ '.woff2': 'font/woff2',
23
+ '.txt': 'text/plain; charset=utf-8',
24
+ };
25
+ // Compiled server.js lives at dist/browser/server.js; the Vite SPA bundle
26
+ // builds into dist/web (see web/vite.config.ts) — siblings under dist/, so
27
+ // this never collides with tsc's own output tree.
28
+ const STATIC_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', 'web');
29
+ function send(res, status, body, contentType) {
30
+ res.writeHead(status, { 'Content-Type': contentType, 'Content-Length': Buffer.byteLength(body) });
31
+ res.end(body);
32
+ }
33
+ function sendJson(res, status, value) {
34
+ send(res, status, JSON.stringify(value), 'application/json; charset=utf-8');
35
+ }
36
+ async function readRequestBody(req) {
37
+ const chunks = [];
38
+ for await (const chunk of req)
39
+ chunks.push(chunk);
40
+ return Buffer.concat(chunks).toString('utf8');
41
+ }
42
+ async function serveStatic(reqPath, res) {
43
+ // Strip query string + guard path traversal. Unknown paths (and `/`) fall
44
+ // back to index.html — there is no server-side routing, the SPA owns it.
45
+ const safePath = reqPath.split('?')[0].replace(/\.\.(\/|\\)/g, '');
46
+ let filePath = join(STATIC_ROOT, safePath === '/' ? '/index.html' : safePath);
47
+ if (!existsSync(filePath))
48
+ filePath = join(STATIC_ROOT, 'index.html');
49
+ try {
50
+ const body = await readFile(filePath);
51
+ const type = MIME[extname(filePath)] ?? 'application/octet-stream';
52
+ res.writeHead(200, { 'Content-Type': type, 'Content-Length': body.length });
53
+ res.end(body);
54
+ }
55
+ catch {
56
+ send(res, 404, 'humanloop: dist/web is missing — run `npm run build` (which builds the web/ SPA too) before opening the browser surface.', 'text/plain; charset=utf-8');
57
+ }
58
+ }
59
+ function wrongSurface(expected) {
60
+ return { error: 'wrong_surface', expected };
61
+ }
62
+ function createServerScaffold() {
63
+ let handleRequest = async () => { };
64
+ const httpServer = createServer((req, res) => {
65
+ void handleRequest(req, res);
66
+ });
67
+ const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
68
+ const sockets = new Set();
69
+ wss.on('connection', (ws) => {
70
+ sockets.add(ws);
71
+ ws.on('close', () => sockets.delete(ws));
72
+ });
73
+ let stopped = false;
74
+ // Resolves once every currently-open socket's `ws.send` has actually
75
+ // flushed its payload to the OS socket buffer — NOT once the browser has
76
+ // received/processed it. Callers that follow a broadcast with a teardown
77
+ // (stop()/closeAllConnections()) must await this first, or the terminate
78
+ // can race the send and the frame never leaves the process.
79
+ function broadcast(message) {
80
+ const payload = JSON.stringify(message);
81
+ const flushed = [];
82
+ for (const ws of sockets) {
83
+ if (ws.readyState !== ws.OPEN)
84
+ continue;
85
+ flushed.push(new Promise((resolveSend) => { ws.send(payload, () => resolveSend()); }));
86
+ }
87
+ return Promise.all(flushed).then(() => undefined);
88
+ }
89
+ function setHandler(next) {
90
+ handleRequest = next;
91
+ }
92
+ function stop() {
93
+ if (stopped)
94
+ return Promise.resolve();
95
+ stopped = true;
96
+ return new Promise((resolveStop) => {
97
+ for (const ws of sockets)
98
+ ws.terminate();
99
+ wss.close();
100
+ // Ephemeral, single-purpose server: force-close any lingering keep-alive
101
+ // sockets rather than waiting on http.Server#close's default drain-first
102
+ // behavior.
103
+ httpServer.closeAllConnections();
104
+ httpServer.close(() => resolveStop());
105
+ });
106
+ }
107
+ return { httpServer, broadcast, setHandler, stop, sockets };
108
+ }
109
+ async function listen(server) {
110
+ await new Promise((resolveListen, rejectListen) => {
111
+ server.once('error', rejectListen);
112
+ server.listen(0, '127.0.0.1', () => resolveListen());
113
+ });
114
+ const address = server.address();
115
+ return typeof address === 'object' && address !== null ? address.port : 0;
116
+ }
117
+ async function startDeckServer(opts) {
118
+ let deck = opts.deck;
119
+ const dir = opts.dir;
120
+ let submitted = null;
121
+ const scaffold = createServerScaffold();
122
+ scaffold.setHandler(async (req, res) => {
123
+ const url = req.url ?? '/';
124
+ if (url === '/api/surface' && req.method === 'GET') {
125
+ sendJson(res, 200, { kind: 'deck' });
126
+ return;
127
+ }
128
+ if (url === '/api/interaction' && req.method === 'GET') {
129
+ // Re-read deck.json in case an agent rewrote it mid-handoff (mirrors
130
+ // the terminal deck's own live-reload poll in resolveInteractionDir).
131
+ // Best-effort — falls back to the in-memory deck if the read fails.
132
+ const onDisk = readJson(deckPath(dir));
133
+ if (onDisk !== null)
134
+ deck = onDisk;
135
+ sendJson(res, 200, { dir, deck });
136
+ return;
137
+ }
138
+ if (url === '/api/submit' && req.method === 'POST') {
139
+ let parsed;
140
+ try {
141
+ parsed = JSON.parse(await readRequestBody(req));
142
+ }
143
+ catch {
144
+ sendJson(res, 400, { error: 'bad_json', message: 'Request body is not valid JSON.' });
145
+ return;
146
+ }
147
+ if (!Array.isArray(parsed.responses)) {
148
+ sendJson(res, 400, { error: 'bad_input', message: 'responses must be an array.' });
149
+ return;
150
+ }
151
+ // Single-assignment: the first accepted submit wins. A later submit
152
+ // (double-click, a second open tab, a retry) never re-writes
153
+ // response.json or re-fires onSubmit — it gets the same canonical
154
+ // result back via 409.
155
+ if (submitted !== null) {
156
+ sendJson(res, 409, { ok: false, error: 'already_submitted', ...submitted });
157
+ return;
158
+ }
159
+ const responses = parsed.responses;
160
+ const completedAt = new Date().toISOString();
161
+ // Canonical write — the exact helper the terminal path uses, so nothing
162
+ // downstream of response.json can tell which surface produced it.
163
+ const responsePath = writeResponse(dir, responses, completedAt, deck);
164
+ submitted = { responsePath, completedAt };
165
+ clearProgress(dir);
166
+ // Await the flush BEFORE registering the finish callback that can
167
+ // trigger caller-side stop() — that ordering is what closes the race
168
+ // between a lost WS frame and the teardown it precedes.
169
+ await scaffold.broadcast({ type: 'converged' });
170
+ // Ack-ordering guarantee: the HTTP caller must always receive its 200
171
+ // body before any lifecycle teardown can close its socket.
172
+ res.once('finish', () => {
173
+ opts.onSubmit?.(responses, completedAt, responsePath);
174
+ });
175
+ sendJson(res, 200, { ok: true, responsePath, completedAt });
176
+ return;
177
+ }
178
+ if (url.startsWith('/api/review')) {
179
+ sendJson(res, 404, wrongSurface('deck'));
180
+ return;
181
+ }
182
+ await serveStatic(url, res);
183
+ });
184
+ const port = await listen(scaffold.httpServer);
185
+ const url = `http://127.0.0.1:${port}/`;
186
+ return {
187
+ url,
188
+ port,
189
+ // No-op: a deck server is only ever started post-handoff (enterHandoff in
190
+ // tui/app.ts), so it has no separate activation moment to gate.
191
+ activate() { },
192
+ async requestTakeBack() {
193
+ await scaffold.broadcast({ type: 'taken-back' });
194
+ },
195
+ stop() {
196
+ return scaffold.stop();
197
+ },
198
+ };
199
+ }
200
+ async function startReviewServer(opts) {
201
+ const { jobDir, file, output, submitFlagPath } = opts;
202
+ const scaffold = createServerScaffold();
203
+ let activated = false;
204
+ let version = 0;
205
+ let ackWaiter = null;
206
+ const TAKE_BACK_ACK_TIMEOUT_MS = opts.takeBackAckTimeoutMs ?? 2000;
207
+ let currentResult = readStoredDraftFeedbackResult(output, file)
208
+ ?? buildDraftFeedbackResult(file, [], new Date().toISOString());
209
+ function refreshInitialDraftFromDisk() {
210
+ if (version !== 0 || currentResult.submitted)
211
+ return;
212
+ const onDisk = readStoredDraftFeedbackResult(output, file);
213
+ if (onDisk === null)
214
+ return;
215
+ if (serializeFeedbackResult(onDisk) !== serializeFeedbackResult(currentResult)) {
216
+ currentResult = onDisk;
217
+ version += 1;
218
+ }
219
+ }
220
+ function currentSnapshot() {
221
+ return currentResult;
222
+ }
223
+ // Bounded wait for `expected` open tabs to POST /api/review/take-back-ack
224
+ // after a take-back-requested broadcast. Resolves early once every tab has
225
+ // acked, or after `timeoutMs` regardless — a tab that never acks (closed,
226
+ // network hiccup) can't hang take-back forever.
227
+ function waitForTakeBackAcks(expected, timeoutMs) {
228
+ if (expected <= 0)
229
+ return Promise.resolve();
230
+ return new Promise((resolveWait) => {
231
+ let settled = false;
232
+ const timer = setTimeout(finish, timeoutMs);
233
+ function finish() {
234
+ if (settled)
235
+ return;
236
+ settled = true;
237
+ clearTimeout(timer);
238
+ ackWaiter = null;
239
+ resolveWait();
240
+ }
241
+ ackWaiter = { expected, count: 0, resolve: finish };
242
+ });
243
+ }
244
+ scaffold.setHandler(async (req, res) => {
245
+ const url = req.url ?? '/';
246
+ if (url === '/api/surface' && req.method === 'GET') {
247
+ sendJson(res, 200, { kind: 'review' });
248
+ return;
249
+ }
250
+ if (url === '/api/review' && req.method === 'GET') {
251
+ refreshInitialDraftFromDisk();
252
+ let content;
253
+ try {
254
+ content = await readFile(file, 'utf8');
255
+ }
256
+ catch (err) {
257
+ sendJson(res, 404, {
258
+ error: 'source_not_found',
259
+ message: err instanceof Error ? err.message : String(err),
260
+ });
261
+ return;
262
+ }
263
+ sendJson(res, 200, {
264
+ kind: 'review',
265
+ file,
266
+ output,
267
+ jobId: basename(jobDir),
268
+ content,
269
+ result: currentSnapshot(),
270
+ version,
271
+ activated,
272
+ });
273
+ return;
274
+ }
275
+ if (url === '/api/review/take-back-ack' && req.method === 'POST') {
276
+ // No activation gate needed — acking a flush request is safe regardless
277
+ // of handoff state.
278
+ if (ackWaiter !== null) {
279
+ ackWaiter.count += 1;
280
+ if (ackWaiter.count >= ackWaiter.expected)
281
+ ackWaiter.resolve();
282
+ }
283
+ sendJson(res, 200, { ok: true });
284
+ return;
285
+ }
286
+ if (url === '/api/review/draft' && req.method === 'PUT') {
287
+ let parsed;
288
+ try {
289
+ parsed = JSON.parse(await readRequestBody(req));
290
+ }
291
+ catch {
292
+ sendJson(res, 400, { error: 'bad_json', message: 'Request body is not valid JSON.' });
293
+ return;
294
+ }
295
+ if (!activated) {
296
+ sendJson(res, 409, { error: 'not_handed_off', message: 'Review authority has not been handed off to the browser yet.' });
297
+ return;
298
+ }
299
+ refreshInitialDraftFromDisk();
300
+ if (currentResult.submitted) {
301
+ sendJson(res, 409, { error: 'already_submitted', result: currentSnapshot() });
302
+ return;
303
+ }
304
+ if (!Number.isInteger(parsed.baseVersion)) {
305
+ sendJson(res, 400, { error: 'bad_input', message: 'baseVersion must be an integer.' });
306
+ return;
307
+ }
308
+ if (parsed.baseVersion !== version) {
309
+ sendJson(res, 409, { error: 'stale_draft', version, result: currentSnapshot() });
310
+ return;
311
+ }
312
+ const parsedComments = parseFeedbackComments(parsed.comments);
313
+ if (!parsedComments.ok) {
314
+ sendJson(res, 400, { error: 'bad_input', message: parsedComments.message });
315
+ return;
316
+ }
317
+ const savedAt = new Date().toISOString();
318
+ const nextResult = buildDraftFeedbackResult(file, parsedComments.comments, savedAt);
319
+ try {
320
+ writeFeedbackResult(output, nextResult);
321
+ }
322
+ catch (err) {
323
+ sendJson(res, 500, { error: 'internal', message: err instanceof Error ? err.message : String(err) });
324
+ return;
325
+ }
326
+ currentResult = nextResult;
327
+ version += 1;
328
+ await scaffold.broadcast({ type: 'review-draft-updated', version, savedAt });
329
+ sendJson(res, 200, { ok: true, result: currentSnapshot(), version });
330
+ return;
331
+ }
332
+ if (url === '/api/review/submit' && req.method === 'POST') {
333
+ let parsed;
334
+ try {
335
+ parsed = JSON.parse(await readRequestBody(req));
336
+ }
337
+ catch {
338
+ sendJson(res, 400, { error: 'bad_json', message: 'Request body is not valid JSON.' });
339
+ return;
340
+ }
341
+ if (!activated) {
342
+ sendJson(res, 409, { error: 'not_handed_off', message: 'Review authority has not been handed off to the browser yet.' });
343
+ return;
344
+ }
345
+ refreshInitialDraftFromDisk();
346
+ if (currentResult.submitted) {
347
+ sendJson(res, 409, {
348
+ ok: false,
349
+ error: 'already_submitted',
350
+ output,
351
+ submittedAt: currentResult.submittedAt,
352
+ result: currentSnapshot(),
353
+ });
354
+ return;
355
+ }
356
+ if (!Number.isInteger(parsed.baseVersion)) {
357
+ sendJson(res, 400, { error: 'bad_input', message: 'baseVersion must be an integer.' });
358
+ return;
359
+ }
360
+ if (parsed.baseVersion !== version) {
361
+ sendJson(res, 409, { error: 'stale_draft', version, result: currentSnapshot() });
362
+ return;
363
+ }
364
+ const parsedComments = parseFeedbackComments(parsed.comments);
365
+ if (!parsedComments.ok) {
366
+ sendJson(res, 400, { error: 'bad_input', message: parsedComments.message });
367
+ return;
368
+ }
369
+ const submittedAt = new Date().toISOString();
370
+ const nextResult = buildFinalFeedbackResult(file, parsedComments.comments, submittedAt);
371
+ try {
372
+ writeFeedbackResult(output, nextResult);
373
+ currentResult = nextResult;
374
+ if (submitFlagPath !== undefined && submitFlagPath.length > 0) {
375
+ writeSubmitFlag(submitFlagPath);
376
+ }
377
+ }
378
+ catch (err) {
379
+ sendJson(res, 500, { error: 'internal', message: err instanceof Error ? err.message : String(err) });
380
+ return;
381
+ }
382
+ // Await the flush BEFORE registering the finish callback that can
383
+ // trigger caller-side stop() — that ordering is what closes the race
384
+ // between a lost WS frame and the teardown it precedes.
385
+ await scaffold.broadcast({ type: 'converged' });
386
+ res.once('finish', () => {
387
+ opts.onSubmit?.(currentSnapshot(), submittedAt, output);
388
+ });
389
+ sendJson(res, 200, { ok: true, output, submittedAt, result: currentSnapshot() });
390
+ return;
391
+ }
392
+ if (url.startsWith('/api/interaction') || url === '/api/submit') {
393
+ sendJson(res, 404, wrongSurface('review'));
394
+ return;
395
+ }
396
+ await serveStatic(url, res);
397
+ });
398
+ const port = await listen(scaffold.httpServer);
399
+ const url = `http://127.0.0.1:${port}/`;
400
+ return {
401
+ url,
402
+ port,
403
+ activate() {
404
+ activated = true;
405
+ },
406
+ async requestTakeBack() {
407
+ const targets = [...scaffold.sockets].filter((ws) => ws.readyState === ws.OPEN);
408
+ if (targets.length > 0) {
409
+ // Arm the ack waiter BEFORE broadcasting take-back-requested — a very
410
+ // fast browser could otherwise flush its dirty draft and POST
411
+ // /api/review/take-back-ack while the broadcast's own ws.send flush
412
+ // callbacks are still resolving. Arming after the broadcast (the old
413
+ // order) drops that ack on the floor (ackWaiter was still null when
414
+ // it arrived), forcing the full timeout even though the flush
415
+ // already succeeded. Starting the waiter first, then awaiting both
416
+ // the broadcast flush and the waiter together, closes that window.
417
+ const acked = waitForTakeBackAcks(targets.length, TAKE_BACK_ACK_TIMEOUT_MS);
418
+ await Promise.all([scaffold.broadcast({ type: 'take-back-requested' }), acked]);
419
+ }
420
+ await scaffold.broadcast({ type: 'taken-back' });
421
+ },
422
+ stop() {
423
+ return scaffold.stop();
424
+ },
425
+ };
426
+ }
427
+ /**
428
+ * Start an on-demand local HTTP+WS server over one interaction dir. Binds to
429
+ * an ephemeral port on 127.0.0.1 only (never 0.0.0.0 — this is a same-machine
430
+ * handoff, not a shareable link). The caller owns the lifecycle: start it
431
+ * when the human hands off to the browser, `stop()` it on take-back or once
432
+ * `onSubmit` fires.
433
+ */
434
+ export async function startWebServer(opts) {
435
+ return startDeckServer(opts);
436
+ }
437
+ /** Start a review-mode browser server over one markdown review job. */
438
+ export async function startReviewWebServer(opts) {
439
+ return startReviewServer(opts);
440
+ }
package/dist/cli.js CHANGED
@@ -6,13 +6,15 @@ import { tmpdir } from 'node:os';
6
6
  import { resolve, join, basename } from 'node:path';
7
7
  import { execFileSync } from 'node:child_process';
8
8
  import { launchReview, reviewVimscript } from './editor/review.js';
9
- import { validateDeck } from './inbox/deck-schema.js';
9
+ import { writeFeedbackResult } from './editor/feedback.js';
10
+ import { validateDeck, resolveDeckBodyPaths } from './inbox/deck-schema.js';
10
11
  import { ask, inbox } from './api.js';
11
12
  import { display } from './surfaces/display.js';
12
13
  import { renderMarkdown, checkMarkdown } from './render/termrender.js';
13
14
  import { scanInbox } from './inbox/scan.js';
14
15
  import { deckPath, atomicWriteJson, readJson, responsePath, stampCanvasNode, } from './inbox/convention.js';
15
16
  import { buildSummary } from './summary.js';
17
+ import { INTERACTION_KINDS } from './types.js';
16
18
  // ── Version ───────────────────────────────────────────────────────────────────
17
19
  const HL_VERSION = '0.2.1';
18
20
  function emitError(err, exitCode = 1) {
@@ -218,7 +220,7 @@ const REQUEST_SCHEMA = {
218
220
  },
219
221
  allowFreetext: { type: 'boolean' },
220
222
  freetextLabel: { type: 'string' },
221
- kind: { type: 'string', enum: ['notify', 'decision', 'context', 'error'] },
223
+ kind: { type: 'string', enum: [...INTERACTION_KINDS] },
222
224
  },
223
225
  },
224
226
  },
@@ -372,6 +374,19 @@ deckCmd
372
374
  }
373
375
  const dir = input.dir ? resolve(input.dir) : mkdtempSync(join(tmpdir(), 'hl-ix-'));
374
376
  mkdirSync(dir, { recursive: true });
377
+ // Canonical bodyPath → body normalization boundary (deck-schema.ts) —
378
+ // resolved before deck.json is ever written, so terminal render, the
379
+ // live-reload poller, and the browser server all just see `body`.
380
+ try {
381
+ deck = resolveDeckBodyPaths(deck, dir);
382
+ }
383
+ catch (bodyErr) {
384
+ emitError({
385
+ error: 'deck_invalid',
386
+ message: bodyErr instanceof Error ? bodyErr.message : String(bodyErr),
387
+ next: 'Fix bodyPath (must point at a real file inside the interaction dir) and retry.',
388
+ });
389
+ }
375
390
  stampCanvasNode(deck);
376
391
  atomicWriteJson(deckPath(dir), deck);
377
392
  const jobId = basename(dir);
@@ -510,6 +525,17 @@ deckCmd
510
525
  next: "The live deck is unchanged. Fix the deck, then: echo '{\"deck\":{...}}' | hl deck validate",
511
526
  });
512
527
  }
528
+ // Same canonical bodyPath → body normalization boundary as `deck ask`.
529
+ try {
530
+ deck = resolveDeckBodyPaths(deck, dir);
531
+ }
532
+ catch (bodyErr) {
533
+ emitError({
534
+ error: 'deck_invalid',
535
+ message: bodyErr instanceof Error ? bodyErr.message : String(bodyErr),
536
+ next: 'The live deck is unchanged. Fix bodyPath (must point at a real file inside the interaction dir) and retry.',
537
+ });
538
+ }
513
539
  atomicWriteJson(deckPath(dir), deck);
514
540
  appendJobLog(dir, {
515
541
  level: 'info', event: 'deck_updated',
@@ -575,7 +601,9 @@ reviewCmd
575
601
  ' harness notifies you when it returns with the result.\n')
576
602
  .helpOption('-h, --help', 'Show help')
577
603
  .action(async () => {
578
- const input = parseStdinJson();
604
+ const input = process.env.HL_REVIEW_OPEN_INPUT
605
+ ? JSON.parse(process.env.HL_REVIEW_OPEN_INPUT)
606
+ : parseStdinJson();
579
607
  if (!input.file || typeof input.file !== 'string') {
580
608
  emitError({ error: 'bad_input', message: 'file is required', field: 'file', next: 'Provide: {"file": "/path/to/doc.md"}' });
581
609
  }
@@ -606,7 +634,7 @@ reviewCmd
606
634
  }
607
635
  const sq = (s) => /^[a-zA-Z0-9_\-./:@%+=]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
608
636
  const childInput = JSON.stringify({ file: absFile, output, editor: input.editor ?? null, tmux: false, dir: jobDir });
609
- const cmd = `echo ${sq(childInput)} | ${sq(process.execPath)} ${sq(scriptPath)} review open`;
637
+ const cmd = `HL_REVIEW_OPEN_INPUT=${sq(childInput)} ${sq(process.execPath)} ${sq(scriptPath)} review open`;
610
638
  try {
611
639
  execFileSync('tmux', ['split-window', '-d', '-h', cmd], { stdio: 'ignore' });
612
640
  }
@@ -623,15 +651,18 @@ reviewCmd
623
651
  await new Promise((resolvePromise) => {
624
652
  const poll = setInterval(() => {
625
653
  const fp = join(jobDir, 'feedback.json');
626
- if (existsSync(fp)) {
627
- const result = tryParseJson(readFileSync(fp, 'utf8'));
628
- if (result !== null) {
629
- clearInterval(poll);
630
- process.stdout.write(JSON.stringify({ job_id: jobId, output, status: 'done', result }) + '\n');
631
- process.exit(0);
632
- }
654
+ const result = existsSync(fp) ? tryParseJson(readFileSync(fp, 'utf8')) : null;
655
+ if (result !== null && result.submitted === true) {
656
+ clearInterval(poll);
657
+ process.stdout.write(JSON.stringify({ job_id: jobId, output, status: 'done', result }) + '\n');
658
+ process.exit(0);
633
659
  }
634
660
  const state = detectJobState(jobDir);
661
+ if (state === 'done' && result !== null) {
662
+ clearInterval(poll);
663
+ process.stdout.write(JSON.stringify({ job_id: jobId, output, status: 'done', result }) + '\n');
664
+ process.exit(0);
665
+ }
635
666
  if (state === 'failed' || state === 'canceled') {
636
667
  clearInterval(poll);
637
668
  process.stdout.write(JSON.stringify({ job_id: jobId, output, status: state }) + '\n');
@@ -652,7 +683,7 @@ reviewCmd
652
683
  jobDir,
653
684
  });
654
685
  appendJobLog(jobDir, { level: 'info', event: 'job_finished', message: 'review finished', data: { comments: result.comments.length } });
655
- writeFileSync(join(jobDir, 'feedback.json'), JSON.stringify(result, null, 2));
686
+ writeFeedbackResult(join(jobDir, 'feedback.json'), result);
656
687
  process.stdout.write(JSON.stringify({
657
688
  job_id: jobId,
658
689
  output,