@crouton-kit/humanloop 0.4.4 → 0.4.5

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,16 @@
1
+ import type { FeedbackResult } from '../types.js';
2
+ export type ParkedReviewAction = {
3
+ type: 'submitted';
4
+ result: FeedbackResult;
5
+ } | {
6
+ type: 'take-back';
7
+ } | {
8
+ type: 'cancel';
9
+ };
10
+ /**
11
+ * Park the terminal while the browser owns the review. Watches raw stdin for
12
+ * `w` (take back) and Ctrl+C (cancel), racing those against the browser's
13
+ * submit promise. `takeBackTarget` names the surface `w` returns to in the
14
+ * parked notice (e.g. "nvim", "the terminal review").
15
+ */
16
+ export declare function waitForParkedReviewSubmit(submitted: Promise<FeedbackResult>, signal: AbortSignal | undefined, takeBackTarget: string): Promise<ParkedReviewAction>;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Park the terminal while the browser owns the review. Watches raw stdin for
3
+ * `w` (take back) and Ctrl+C (cancel), racing those against the browser's
4
+ * submit promise. `takeBackTarget` names the surface `w` returns to in the
5
+ * parked notice (e.g. "nvim", "the terminal review").
6
+ */
7
+ export async function waitForParkedReviewSubmit(submitted, signal, takeBackTarget) {
8
+ process.stderr.write('\nhumanloop: browser review handoff is active.\n' +
9
+ ' The terminal surface is parked; the browser is the editing authority.\n' +
10
+ ` Press w to take back into ${takeBackTarget}, or Ctrl+C to exit with an unsubmitted draft.\n\n`);
11
+ return new Promise((resolveAction) => {
12
+ const stdin = process.stdin;
13
+ const interactive = stdin.isTTY;
14
+ const wasRaw = stdin.isRaw;
15
+ let settled = false;
16
+ const cleanup = () => {
17
+ signal?.removeEventListener('abort', onAbort);
18
+ if (!interactive)
19
+ return;
20
+ stdin.off('data', onData);
21
+ if (stdin.setRawMode)
22
+ stdin.setRawMode(wasRaw);
23
+ stdin.pause();
24
+ };
25
+ const finish = (action) => {
26
+ if (settled)
27
+ return;
28
+ settled = true;
29
+ cleanup();
30
+ resolveAction(action);
31
+ };
32
+ const onAbort = () => finish({ type: 'cancel' });
33
+ const onData = (chunk) => {
34
+ const text = chunk.toString('utf8');
35
+ if (text === 'w' || text === 'W')
36
+ finish({ type: 'take-back' });
37
+ if (text === '\u0003')
38
+ finish({ type: 'cancel' });
39
+ };
40
+ if (interactive) {
41
+ stdin.setRawMode(true);
42
+ stdin.resume();
43
+ stdin.on('data', onData);
44
+ }
45
+ signal?.addEventListener('abort', onAbort, { once: true });
46
+ if (signal?.aborted)
47
+ onAbort();
48
+ submitted.then((result) => finish({ type: 'submitted', result }), () => finish({ type: 'cancel' }));
49
+ });
50
+ }
@@ -12,7 +12,8 @@ export interface ReviewOptions {
12
12
  onClose?: () => Promise<void> | void;
13
13
  /** Controller cancellation closes the editor/browser handoff without proposing a result. */
14
14
  signal?: AbortSignal;
15
- /** Reuse this controller-owned directory for `review.vim`; an existing script is left untouched. */
15
+ /** Reuse this controller-owned directory for review artifacts (`review.vim`,
16
+ * the browser-handoff job id); an existing script is left untouched. */
16
17
  jobDir?: string;
17
18
  }
18
19
  export declare function reviewVimscript(): string;
@@ -6,6 +6,7 @@ import { openBrowser } from '../browser/open.js';
6
6
  import { startReviewWebServer } from '../browser/server.js';
7
7
  import { buildFinalFeedbackResult, readReviewDraft, } from './feedback.js';
8
8
  import { launchTerminalReview } from './terminal-review.js';
9
+ import { waitForParkedReviewSubmit } from './parked.js';
9
10
  function resolveEditor(override) {
10
11
  const candidates = override ? [override] : ['nvim', 'vim'];
11
12
  for (const bin of candidates) {
@@ -551,50 +552,6 @@ function draftFeedback(path, absFile) {
551
552
  savedAt: draft?.savedAt ?? new Date().toISOString(),
552
553
  };
553
554
  }
554
- async function waitForParkedReviewSubmit(submitted, signal) {
555
- process.stderr.write('\nhumanloop: browser review handoff is active.\n' +
556
- ' The terminal editor is parked; the browser is the editing authority.\n' +
557
- ' Press w to take back into nvim, or Ctrl+C to exit with an unsubmitted draft.\n\n');
558
- return new Promise((resolveAction) => {
559
- const stdin = process.stdin;
560
- const interactive = stdin.isTTY;
561
- const wasRaw = stdin.isRaw;
562
- let settled = false;
563
- const cleanup = () => {
564
- signal?.removeEventListener('abort', onAbort);
565
- if (!interactive)
566
- return;
567
- stdin.off('data', onData);
568
- if (stdin.setRawMode)
569
- stdin.setRawMode(wasRaw);
570
- stdin.pause();
571
- };
572
- const finish = (action) => {
573
- if (settled)
574
- return;
575
- settled = true;
576
- cleanup();
577
- resolveAction(action);
578
- };
579
- const onAbort = () => finish({ type: 'cancel' });
580
- const onData = (chunk) => {
581
- const text = chunk.toString('utf8');
582
- if (text === 'w' || text === 'W')
583
- finish({ type: 'take-back' });
584
- if (text === '\u0003')
585
- finish({ type: 'cancel' });
586
- };
587
- if (interactive) {
588
- stdin.setRawMode(true);
589
- stdin.resume();
590
- stdin.on('data', onData);
591
- }
592
- signal?.addEventListener('abort', onAbort, { once: true });
593
- if (signal?.aborted)
594
- onAbort();
595
- submitted.then((result) => finish({ type: 'submitted', result }), () => finish({ type: 'cancel' }));
596
- });
597
- }
598
555
  async function stopReviewServer(handle) {
599
556
  if (handle !== null)
600
557
  await handle.stop();
@@ -690,12 +647,16 @@ async function launchNeovimReview(file, opts) {
690
647
  server.activate();
691
648
  process.stderr.write(`humanloop: browser review handoff active — ${server.url}\n`);
692
649
  openBrowser(server.url);
693
- const action = await waitForParkedReviewSubmit(submitted, opts.signal);
650
+ const action = await waitForParkedReviewSubmit(submitted, opts.signal, 'nvim');
694
651
  if (action.type === 'submitted') {
695
652
  await stopReviewServer(server);
696
653
  clearFlag(handoffFlagPath);
697
- if (!opts.signal?.aborted)
698
- await opts.onPropose?.(action.result);
654
+ // An abort landing during the async stop must not produce a submitted
655
+ // result whose canonical onPropose convergence was skipped — fall
656
+ // back to the disk draft (the browser submit already persisted it).
657
+ if (opts.signal?.aborted)
658
+ return draftFeedback(outPath, absFile);
659
+ await opts.onPropose?.(action.result);
699
660
  return action.result;
700
661
  }
701
662
  if (action.type === 'take-back') {
@@ -711,9 +672,12 @@ async function launchNeovimReview(file, opts) {
711
672
  }
712
673
  await stopReviewServer(server);
713
674
  if (existsSync(submitFlagPath)) {
675
+ // Same abort-during-stop race as the parked branch above: an aborted
676
+ // review must resolve as a draft, never as an unconverged proposal.
677
+ if (opts.signal?.aborted)
678
+ return draftFeedback(outPath, absFile);
714
679
  const proposal = proposedFeedback(outPath, absFile);
715
- if (!opts.signal?.aborted)
716
- await opts.onPropose?.(proposal);
680
+ await opts.onPropose?.(proposal);
717
681
  return proposal;
718
682
  }
719
683
  return draftFeedback(outPath, absFile);
@@ -107,9 +107,11 @@ export declare function renderReviewFrame(state: ReviewState, fileLabel: string,
107
107
  * document renders via termrender (Mermaid included), the anchor is a
108
108
  * highlighted block in the rendered document itself (gutter bar + tint via
109
109
  * termrender's row→source map), and the human explicitly submits a proposal.
110
- * Never edits the source file. Autosaves the comment draft to `opts.output`
111
- * (the `progress.json` convention). Canonical ticket finalization stays with
112
- * the caller's `onPropose` (the adapter calls `completeReview`); this function
113
- * never writes `response.json`.
110
+ * `space w` hands the review off to the browser surface (same park/take-back
111
+ * semantics as the Neovim path); the draft round-trips between surfaces via
112
+ * `opts.output`. Never edits the source file. Autosaves the comment draft to
113
+ * `opts.output` (the `progress.json` convention). Canonical ticket
114
+ * finalization stays with the caller's `onPropose` (the adapter calls
115
+ * `completeReview`); this function never writes `response.json`.
114
116
  */
115
117
  export declare function launchTerminalReview(file: string, opts: ReviewOptions): Promise<FeedbackResult>;
@@ -1,7 +1,11 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { readFileSync } from 'node:fs';
3
- import { basename, resolve } from 'node:path';
2
+ import { mkdtempSync, readFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { basename, join, resolve } from 'node:path';
4
5
  import stringWidth from 'string-width';
6
+ import { waitForParkedReviewSubmit } from './parked.js';
7
+ import { openBrowser } from '../browser/open.js';
8
+ import { startReviewWebServer } from '../browser/server.js';
5
9
  import { buildDraftFeedbackResult, buildFinalFeedbackResult, readReviewDraft, writeReviewDraft } from './feedback.js';
6
10
  import { renderMarkdownWithMap } from '../render/termrender.js';
7
11
  import { setupTerminal, restoreTerminal, getTerminalSize, parseKeypress } from '../tui/terminal.js';
@@ -280,7 +284,7 @@ export function reservedRows(state, cols) {
280
284
  }
281
285
  if (state.mode === 'list')
282
286
  return header + Math.max(1, state.comments.length) + 2;
283
- return header + 8; // help
287
+ return header + 9; // help
284
288
  }
285
289
  export function renderReviewFrame(state, fileLabel, doc, cols, rows) {
286
290
  const maxW = Math.min(Math.max(20, cols - 4), 120);
@@ -321,13 +325,14 @@ export function renderReviewFrame(state, fileLabel, doc, cols, rows) {
321
325
  footer.push(` ${DIM}u/d, pgup/pgdn${RESET} scroll document`);
322
326
  footer.push(` ${DIM}space c${RESET} compose comment ${DIM}space l${RESET} list comments`);
323
327
  footer.push(` ${DIM}space u${RESET} undo last comment ${DIM}space s${RESET} submit review`);
328
+ footer.push(` ${DIM}space w${RESET} hand off to browser`);
324
329
  footer.push(` ${DIM}?${RESET} toggle this help ${DIM}esc${RESET} close/cancel`);
325
330
  footer.push('');
326
331
  footer.push(` ${DIM}esc / ?${RESET} close`);
327
332
  }
328
333
  else {
329
334
  footer.push(` ${DIM}j/k${RESET} block ${DIM}shift-j/k${RESET} extend ${DIM}u/d${RESET} scroll ${DIM}space c${RESET} comment ` +
330
- `${DIM}space l${RESET} list ${DIM}space u${RESET} undo ${DIM}space s${RESET} submit ${DIM}?${RESET} help ${DIM}esc${RESET} close`);
335
+ `${DIM}space l${RESET} list ${DIM}space u${RESET} undo ${DIM}space s${RESET} submit ${DIM}space w${RESET} browser ${DIM}?${RESET} help ${DIM}esc${RESET} close`);
331
336
  }
332
337
  const reserved = header.length + footer.length;
333
338
  const bodyHeight = Math.max(1, rows - reserved);
@@ -363,26 +368,85 @@ export function renderReviewFrame(state, fileLabel, doc, cols, rows) {
363
368
  lines.push('');
364
369
  return lines.slice(0, rows);
365
370
  }
366
- // ── Host loop (impure — the only part that touches the real TTY) ───────────
371
+ function diskDraftResult(absFile, outPath) {
372
+ const draft = readReviewDraft(outPath);
373
+ return buildDraftFeedbackResult(absFile, draft?.comments ?? [], draft?.savedAt);
374
+ }
367
375
  /**
368
376
  * Open a Markdown file in humanloop's own terminal review surface: the
369
377
  * document renders via termrender (Mermaid included), the anchor is a
370
378
  * highlighted block in the rendered document itself (gutter bar + tint via
371
379
  * termrender's row→source map), and the human explicitly submits a proposal.
372
- * Never edits the source file. Autosaves the comment draft to `opts.output`
373
- * (the `progress.json` convention). Canonical ticket finalization stays with
374
- * the caller's `onPropose` (the adapter calls `completeReview`); this function
375
- * never writes `response.json`.
380
+ * `space w` hands the review off to the browser surface (same park/take-back
381
+ * semantics as the Neovim path); the draft round-trips between surfaces via
382
+ * `opts.output`. Never edits the source file. Autosaves the comment draft to
383
+ * `opts.output` (the `progress.json` convention). Canonical ticket
384
+ * finalization stays with the caller's `onPropose` (the adapter calls
385
+ * `completeReview`); this function never writes `response.json`.
376
386
  */
377
387
  export async function launchTerminalReview(file, opts) {
378
388
  const absFile = resolve(file);
379
389
  const content = readFileSync(absFile, 'utf8');
380
- const sourceLines = content.split('\n');
381
390
  const outPath = resolve(opts.output);
382
- const draft = readReviewDraft(outPath);
383
391
  const fileLabel = basename(absFile);
384
- if (opts.signal?.aborted)
385
- return buildDraftFeedbackResult(absFile, draft?.comments ?? [], draft?.savedAt);
392
+ // Created lazily on the first browser handoff, reused across take-backs.
393
+ let jobDir = opts.jobDir ?? null;
394
+ while (true) {
395
+ if (opts.signal?.aborted)
396
+ return diskDraftResult(absFile, outPath);
397
+ const outcome = await runTerminalReviewSession(absFile, content, outPath, fileLabel, opts);
398
+ if (outcome.type === 'done')
399
+ return outcome.result;
400
+ // Browser handoff: the TUI is parked (terminal already restored, draft
401
+ // persisted); the browser is the editing authority until it submits, the
402
+ // human takes back with `w`, or the review is cancelled.
403
+ if (jobDir === null)
404
+ jobDir = mkdtempSync(join(tmpdir(), 'hl-review-'));
405
+ let resolveSubmitted;
406
+ const submitted = new Promise((resolveSubmittedPromise) => {
407
+ resolveSubmitted = resolveSubmittedPromise;
408
+ });
409
+ const server = await startReviewWebServer({
410
+ jobDir,
411
+ file: absFile,
412
+ output: outPath,
413
+ onSubmit: (result) => resolveSubmitted(result),
414
+ });
415
+ if (opts.signal?.aborted) {
416
+ await server.stop();
417
+ return diskDraftResult(absFile, outPath);
418
+ }
419
+ server.activate();
420
+ process.stderr.write(`humanloop: browser review handoff active — ${server.url}\n`);
421
+ openBrowser(server.url);
422
+ const action = await waitForParkedReviewSubmit(submitted, opts.signal, 'the terminal review');
423
+ if (action.type === 'submitted') {
424
+ await server.stop();
425
+ // An abort landing during the async stop() must not produce a submitted
426
+ // result whose canonical onPropose convergence was skipped — fall back
427
+ // to the disk draft (the browser submit already persisted its comments).
428
+ if (opts.signal?.aborted)
429
+ return diskDraftResult(absFile, outPath);
430
+ await opts.onPropose?.(action.result);
431
+ return action.result;
432
+ }
433
+ if (action.type === 'take-back') {
434
+ await server.requestTakeBack();
435
+ await server.stop();
436
+ process.stderr.write('humanloop: taking review back into the terminal review.\n');
437
+ continue; // re-enter the TUI; the next session re-reads the draft from disk
438
+ }
439
+ await server.stop();
440
+ return diskDraftResult(absFile, outPath);
441
+ }
442
+ }
443
+ /** One TUI session over the document. Resolves when the review reaches a
444
+ * final result (submit/close/cancel) or the human requests browser handoff;
445
+ * either way the terminal is restored before resolution. Re-reads the draft
446
+ * from disk on entry so browser edits survive a take-back. */
447
+ function runTerminalReviewSession(absFile, content, outPath, fileLabel, opts) {
448
+ const sourceLines = content.split('\n');
449
+ const draft = readReviewDraft(outPath);
386
450
  setupTerminal();
387
451
  let { cols, rows } = getTerminalSize();
388
452
  let doc = renderMarkdownWithMap(content, renderWidthForCols(cols));
@@ -410,7 +474,7 @@ export async function launchTerminalReview(file, opts) {
410
474
  process.stdout.write('\x1b[?2026l');
411
475
  prevFrame = nextPrevFrame;
412
476
  };
413
- const finish = (result) => {
477
+ const finishWith = (outcome) => {
414
478
  if (settled)
415
479
  return;
416
480
  settled = true;
@@ -418,8 +482,9 @@ export async function launchTerminalReview(file, opts) {
418
482
  process.stdout.removeListener('resize', onResize);
419
483
  opts.signal?.removeEventListener('abort', onAbort);
420
484
  restoreTerminal();
421
- resolvePromise(result);
485
+ resolvePromise(outcome);
422
486
  };
487
+ const finish = (result) => finishWith({ type: 'done', result });
423
488
  const onResize = () => {
424
489
  ({ cols, rows } = getTerminalSize());
425
490
  doc = renderMarkdownWithMap(content, renderWidthForCols(cols));
@@ -575,6 +640,11 @@ export async function launchTerminalReview(file, opts) {
575
640
  void submit();
576
641
  return;
577
642
  }
643
+ else if (input === 'w') {
644
+ persist();
645
+ finishWith({ type: 'handoff' });
646
+ return;
647
+ }
578
648
  paint();
579
649
  return;
580
650
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/humanloop",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "Human-in-the-loop decision TUI — agents write questions, humans answer them",
5
5
  "engines": {
6
6
  "node": ">=22.19.0"