@crouton-kit/humanloop 0.3.33 → 0.3.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/api.d.ts +4 -8
  2. package/dist/api.js +6 -29
  3. package/dist/browser/server.d.ts +3 -3
  4. package/dist/browser/server.js +22 -25
  5. package/dist/cli.js +131 -1114
  6. package/dist/editor/feedback.d.ts +9 -0
  7. package/dist/editor/feedback.js +23 -0
  8. package/dist/editor/review.d.ts +12 -30
  9. package/dist/editor/review.js +109 -119
  10. package/dist/inbox/claim.d.ts +23 -0
  11. package/dist/inbox/claim.js +67 -0
  12. package/dist/inbox/completion.d.ts +4 -0
  13. package/dist/inbox/completion.js +107 -0
  14. package/dist/inbox/controller.d.ts +74 -0
  15. package/dist/inbox/controller.js +457 -0
  16. package/dist/inbox/convention.d.ts +15 -10
  17. package/dist/inbox/convention.js +149 -79
  18. package/dist/inbox/deck-adapter.d.ts +27 -0
  19. package/dist/inbox/deck-adapter.js +57 -0
  20. package/dist/inbox/deck-schema.d.ts +18 -14
  21. package/dist/inbox/deck-schema.js +59 -117
  22. package/dist/inbox/layout.d.ts +8 -0
  23. package/dist/inbox/layout.js +9 -0
  24. package/dist/inbox/registry.d.ts +29 -0
  25. package/dist/inbox/registry.js +97 -0
  26. package/dist/inbox/review-adapter.d.ts +24 -0
  27. package/dist/inbox/review-adapter.js +57 -0
  28. package/dist/inbox/scan.d.ts +3 -2
  29. package/dist/inbox/scan.js +71 -43
  30. package/dist/inbox/tickets.d.ts +63 -0
  31. package/dist/inbox/tickets.js +247 -0
  32. package/dist/inbox/tui.d.ts +3 -6
  33. package/dist/inbox/tui.js +24 -112
  34. package/dist/index.d.ts +12 -3
  35. package/dist/index.js +8 -2
  36. package/dist/render/termrender.d.ts +5 -0
  37. package/dist/render/termrender.js +63 -4
  38. package/dist/summary.d.ts +2 -3
  39. package/dist/summary.js +2 -3
  40. package/dist/surfaces/inbox-popup.d.ts +2 -0
  41. package/dist/surfaces/inbox-popup.js +32 -0
  42. package/dist/tui/app.js +13 -0
  43. package/dist/tui/tmux.d.ts +30 -7
  44. package/dist/tui/tmux.js +190 -66
  45. package/dist/types.d.ts +68 -7
  46. package/package.json +2 -2
@@ -15,4 +15,13 @@ export declare function serializeFeedbackResult(result: FeedbackResult): string;
15
15
  export declare function writeFeedbackResult(path: string, result: FeedbackResult): string;
16
16
  export declare function writeDraftFeedbackResult(path: string, file: string, comments: FeedbackComment[], savedAt?: string): FeedbackResult;
17
17
  export declare function writeFinalFeedbackResult(path: string, file: string, comments: FeedbackComment[], timestamp?: string): FeedbackResult;
18
+ export interface ReviewDraft {
19
+ kind: 'review';
20
+ comments: FeedbackComment[];
21
+ savedAt: string;
22
+ version: number;
23
+ }
24
+ /** Durable, resumable review work. Canonical feedback is produced only by the ticket finalizer. */
25
+ export declare function readReviewDraft(path: string): ReviewDraft | null;
26
+ export declare function writeReviewDraft(path: string, comments: FeedbackComment[], version: number, savedAt?: string): ReviewDraft;
18
27
  export declare function writeSubmitFlag(path: string): void;
@@ -198,6 +198,29 @@ export function writeFinalFeedbackResult(path, file, comments, timestamp = nowIs
198
198
  writeFeedbackResult(path, result);
199
199
  return result;
200
200
  }
201
+ /** Durable, resumable review work. Canonical feedback is produced only by the ticket finalizer. */
202
+ export function readReviewDraft(path) {
203
+ if (!existsSync(path))
204
+ return null;
205
+ let raw;
206
+ try {
207
+ raw = JSON.parse(readFileSync(path, 'utf8'));
208
+ }
209
+ catch {
210
+ return null;
211
+ }
212
+ if (!isRecord(raw) || raw.kind !== 'review' || !Number.isInteger(raw.version) || raw.version < 0 || typeof raw.savedAt !== 'string')
213
+ return null;
214
+ return { kind: 'review', comments: sanitizeFeedbackComments(raw.comments), savedAt: raw.savedAt, version: raw.version };
215
+ }
216
+ export function writeReviewDraft(path, comments, version, savedAt = nowIso()) {
217
+ const draft = { kind: 'review', comments: sanitizeFeedbackComments(comments), savedAt, version };
218
+ const tmp = `${path}.tmp`;
219
+ mkdirSync(dirname(path), { recursive: true });
220
+ writeFileSync(tmp, `${JSON.stringify(draft, null, 2)}\n`);
221
+ renameSync(tmp, path);
222
+ return draft;
223
+ }
201
224
  export function writeSubmitFlag(path) {
202
225
  mkdirSync(dirname(path), { recursive: true });
203
226
  writeFileSync(path, '');
@@ -1,35 +1,18 @@
1
1
  import type { FeedbackResult } from '../types.js';
2
2
  export interface ReviewOptions {
3
- /** Where the answers JSON is written (live autosave + finalized on exit). */
3
+ /** Where the resumable review draft is written. */
4
4
  output: string;
5
5
  /** Editor binary override. Default: first of nvim, vim on PATH. */
6
6
  editor?: string;
7
- /** Force running in the current terminal even when $TMUX is set. */
8
- noTmux?: boolean;
9
- /**
10
- * When set, an explicit `<Space>s` / `:HLSubmit` invocation writes a sentinel
11
- * file at this path. Node-side uses the file's existence to gate `submitted: true`.
12
- * When omitted, any editor exit finalizes `submitted: true` (legacy behavior).
13
- */
14
- submitFlagPath?: string;
15
- /**
16
- * When true and $TMUX is set and !opts.noTmux, launch via
17
- * `tmux display-popup -E` instead of `tmux split-window`.
18
- * display-popup -E is synchronous so no poll loop is needed.
19
- */
20
- tmuxPopup?: boolean;
21
- /** Override the default 90%/90% popup size. Only used when tmuxPopup is true. */
22
- popupSize?: {
23
- w: string;
24
- h: string;
25
- };
26
- /**
27
- * Reuse this directory for `review.vim` instead of minting a fresh temp dir.
28
- * The async `review open` kickoff passes its job dir so the detached child
29
- * sources the same vimscript the parent already wrote there (which also lets
30
- * the job be recognized as a review while it's still live). When the file
31
- * already exists it is left untouched.
32
- */
7
+ /** Called exactly once when the human explicitly submits a final proposal. */
8
+ onPropose?: (result: FeedbackResult) => Promise<void> | void;
9
+ /** Fired when the human presses Option/Alt+I inside the editor: a request to
10
+ * gracefully close the WHOLE inbox (not submit, not return-to-list). The
11
+ * draft is saved and the ticket stays pending. */
12
+ onClose?: () => Promise<void> | void;
13
+ /** Controller cancellation closes the editor/browser handoff without proposing a result. */
14
+ signal?: AbortSignal;
15
+ /** Reuse this controller-owned directory for `review.vim`; an existing script is left untouched. */
33
16
  jobDir?: string;
34
17
  }
35
18
  export declare function reviewVimscript(): string;
@@ -43,8 +26,7 @@ export declare function formatFeedbackSummary(result: FeedbackResult, feedbackJs
43
26
  /**
44
27
  * Open a markdown file in a clean, read-only Neovim/Vim review session. The
45
28
  * human anchors comments to source lines/selections with native vim motions
46
- * and quits to submit. Blocks until the editor exits, then finalizes and
47
- * returns the feedback. Autosaved continuously so a kill is recoverable and
48
- * the next run resumes.
29
+ * and explicitly submits a proposal. Blocks until the editor exits. Autosaved
30
+ * drafts survive exit; canonical ticket finalization belongs to the controller.
49
31
  */
50
32
  export declare function launchReview(file: string, opts: ReviewOptions): Promise<FeedbackResult>;
@@ -1,15 +1,10 @@
1
- import { spawn, spawnSync, execFileSync } from 'child_process';
1
+ import { spawn, spawnSync } from 'child_process';
2
2
  import { existsSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
3
3
  import { tmpdir } from 'os';
4
4
  import { resolve, join } from 'path';
5
5
  import { openBrowser } from '../browser/open.js';
6
6
  import { startReviewWebServer } from '../browser/server.js';
7
- import { readStoredFeedbackResult, writeDraftFeedbackResult, writeFinalFeedbackResult, } from './feedback.js';
8
- function shellQuote(s) {
9
- if (s.length > 0 && /^[a-zA-Z0-9_\-./:@%+=]+$/.test(s))
10
- return s;
11
- return `'${s.replace(/'/g, `'\\''`)}'`;
12
- }
7
+ import { buildFinalFeedbackResult, readReviewDraft, } from './feedback.js';
13
8
  function resolveEditor(override) {
14
9
  const candidates = override ? [override] : ['nvim', 'vim'];
15
10
  for (const bin of candidates) {
@@ -41,6 +36,7 @@ export function reviewVimscript() {
41
36
  `let g:hl_review_url = $HL_REVIEW_URL`,
42
37
  `let g:hl_handoff_flag = $HL_HANDOFF_FLAG`,
43
38
  `let s:comments = []`,
39
+ `let s:version = 0`,
44
40
  `let s:idseq = 0`,
45
41
  `let s:ns = exists('*nvim_create_namespace') ? nvim_create_namespace('hl_review') : -1`,
46
42
  ``,
@@ -50,15 +46,17 @@ export function reviewVimscript() {
50
46
  ` endif`,
51
47
  ` try`,
52
48
  ` let l:obj = json_decode(join(readfile(g:hl_out), "\\n"))`,
53
- ` if type(l:obj) == type({}) && get(l:obj,'file','') ==# g:hl_src && !get(l:obj,'submitted',0)`,
49
+ ` if type(l:obj) == type({}) && get(l:obj,'kind','') ==# 'review'`,
54
50
  ` let s:comments = get(l:obj,'comments',[])`,
51
+ ` let s:version = get(l:obj,'version',0)`,
55
52
  ` endif`,
56
53
  ` catch`,
57
54
  ` endtry`,
58
55
  `endfunction`,
59
56
  ``,
60
57
  `function! s:Save() abort`,
61
- ` let l:obj = {'file': g:hl_src, 'submitted': v:false, 'approved': v:false, 'comments': s:comments, 'savedAt': strftime('%Y-%m-%dT%H:%M:%S')}`,
58
+ ` let s:version += 1`,
59
+ ` let l:obj = {'kind': 'review', 'comments': s:comments, 'savedAt': strftime('%Y-%m-%dT%H:%M:%S'), 'version': s:version}`,
62
60
  ` let l:tmp = g:hl_out . '.tmp'`,
63
61
  ` call writefile([json_encode(l:obj)], l:tmp)`,
64
62
  ` call rename(l:tmp, g:hl_out)`,
@@ -311,6 +309,17 @@ export function reviewVimscript() {
311
309
  ` qa!`,
312
310
  `endfunction`,
313
311
  ``,
312
+ `" Option/Alt+I: request graceful close of the whole inbox. Saves the draft`,
313
+ `" (ticket stays PENDING — this is close, never submit) and quits nvim; the`,
314
+ `" controller reads the close flag on editor exit and dismisses the popup.`,
315
+ `function! s:Close() abort`,
316
+ ` call s:Save()`,
317
+ ` if $HL_CLOSE_FLAG !=# ''`,
318
+ ` call writefile([''], $HL_CLOSE_FLAG)`,
319
+ ` endif`,
320
+ ` qa!`,
321
+ `endfunction`,
322
+ ``,
314
323
  `function! s:HandOff() abort`,
315
324
  ` call s:Save()`,
316
325
  ` if g:hl_handoff_flag ==# ''`,
@@ -326,7 +335,13 @@ export function reviewVimscript() {
326
335
  `command! HLUndo call <SID>Undo()`,
327
336
  `command! HLSubmit call <SID>Submit()`,
328
337
  `command! HLBrowser call <SID>HandOff()`,
329
- `command! HLHelp echo 'REVIEW <Space>c comment <Space>l list <Space>u undo-last <Space>s submit & quit <Space>w browser handoff | in list: e/<CR> edit · dd delete · q close'`,
338
+ `command! HLClose call <SID>Close()`,
339
+ `" M-i (Option/Alt+I) closes the whole inbox from any mode, mirroring the`,
340
+ `" controller's list/deck close chord. Global (not buffer-local) so it fires`,
341
+ `" from the source buffer, the comment scratch buffer, and the list.`,
342
+ `nnoremap <silent> <M-i> :call <SID>Close()<CR>`,
343
+ `inoremap <silent> <M-i> <C-\\><C-o>:call <SID>Close()<CR>`,
344
+ `command! HLHelp echo 'REVIEW <Space>c comment <Space>l list <Space>u undo-last <Space>s submit & quit <Space>w browser handoff M-i close inbox | in list: e/<CR> edit · dd delete · q close'`,
330
345
  ``,
331
346
  `" Highlights are (re)applied after any colorscheme so the user's theme`,
332
347
  `" (e.g. gloam) loads first, then our anchor highlight sits on top.`,
@@ -428,7 +443,7 @@ export function reviewVimscript() {
428
443
  ``,
429
444
  `" Watchmode: the source is opened read-only here but may be rewritten on`,
430
445
  `" disk by the agent while the human reviews. autoread + a periodic checktime`,
431
- `" (timers fire even when the tmux pane is unfocused) reload the buffer`,
446
+ `" (timers fire even when the terminal is unfocused) reload the buffer`,
432
447
  `" silently — it is never locally modified, so there is nothing to clobber.`,
433
448
  `" On reload, re-run Setup (read-only guard, treesitter, maps) and redraw the`,
434
449
  `" comment anchors, which the reload cleared. Comments live in s:comments /`,
@@ -496,78 +511,58 @@ export function formatFeedbackSummary(result, feedbackJsonPath) {
496
511
  out.push(` schema: ${FEEDBACK_SCHEMA} · cols are 0-based byte offsets, colEnd exclusive`);
497
512
  return out.join('\n');
498
513
  }
499
- async function runInTmuxPane(paneCmd) {
500
- // `paneCmd` MUST be an `exec`-prefixed command so the editor replaces the
501
- // shell and becomes the pane's process. tmux's `#{pane_current_command}`
502
- // then reports `nvim` — many users gate "let Ctrl-D/Ctrl-U/etc. through to
503
- // the app vs. take over with tmux copy-mode" on exactly that. If the pane
504
- // command were `nvim …; tmux wait-for` the pane process stays the shell,
505
- // pane_current_command is `zsh`/`sh`, and those bindings hijack native vim
506
- // keys. Because the editor is exec'd there is no "after" to signal from, so
507
- // completion is detected purely by the pane disappearing on exit.
508
- const paneId = execFileSync('tmux', ['split-window', '-h', '-d', '-P', '-F', '#{pane_id}', paneCmd], { encoding: 'utf8' }).trim();
509
- // Ensure the pane closes when the editor exits (some configs set
510
- // remain-on-exit globally, which would hang the poll below).
511
- try {
512
- execFileSync('tmux', ['set-option', '-p', '-t', paneId, 'remain-on-exit', 'off'], { stdio: 'ignore' });
513
- }
514
- catch {
515
- // older tmux without -p pane scope — default is already 'off'
516
- }
517
- await new Promise((resolvePromise, rejectPromise) => {
518
- let settled = false;
519
- const finish = (fn) => { if (!settled) {
520
- settled = true;
521
- clearInterval(poll);
522
- fn();
523
- } };
524
- // The editor IS the pane process; when it exits the pane is destroyed.
525
- const poll = setInterval(() => {
526
- try {
527
- const panes = execFileSync('tmux', ['list-panes', '-a', '-F', '#{pane_id}'], { encoding: 'utf8' });
528
- if (!panes.split('\n').map((s) => s.trim()).includes(paneId)) {
529
- finish(resolvePromise);
530
- }
531
- }
532
- catch (e) {
533
- finish(() => rejectPromise(new Error(`tmux list-panes failed: ${e instanceof Error ? e.message : String(e)}`)));
534
- }
535
- }, 200);
536
- });
537
- }
538
- function runInCurrentTerminal(bin, args, env) {
514
+ function runInCurrentTerminal(bin, args, env, signal) {
539
515
  return new Promise((resolvePromise, rejectPromise) => {
540
516
  const child = spawn(bin, args, { stdio: 'inherit', env });
541
- child.on('error', rejectPromise);
542
- child.on('exit', () => resolvePromise());
517
+ let settled = false;
518
+ const cleanup = () => signal?.removeEventListener('abort', abort);
519
+ const finish = (error) => {
520
+ if (settled)
521
+ return;
522
+ settled = true;
523
+ cleanup();
524
+ if (error === undefined)
525
+ resolvePromise();
526
+ else
527
+ rejectPromise(error);
528
+ };
529
+ const abort = () => child.kill('SIGTERM');
530
+ child.once('error', finish);
531
+ child.once('exit', () => finish());
532
+ signal?.addEventListener('abort', abort, { once: true });
533
+ if (signal?.aborted)
534
+ abort();
543
535
  });
544
536
  }
545
537
  function clearFlag(path) {
546
538
  rmSync(path, { force: true });
547
539
  }
548
- function readDraftComments(path, absFile) {
549
- const stored = readStoredFeedbackResult(path, absFile);
550
- return stored?.comments ?? [];
540
+ function proposedFeedback(path, absFile) {
541
+ return buildFinalFeedbackResult(absFile, readReviewDraft(path)?.comments ?? []);
551
542
  }
552
- function finalizeReviewOutput(outPath, absFile, didSubmit) {
553
- const comments = readDraftComments(outPath, absFile);
554
- return didSubmit
555
- ? writeFinalFeedbackResult(outPath, absFile, comments)
556
- : writeDraftFeedbackResult(outPath, absFile, comments);
543
+ function draftFeedback(path, absFile) {
544
+ const draft = readReviewDraft(path);
545
+ return {
546
+ file: absFile,
547
+ submitted: false,
548
+ approved: false,
549
+ comments: draft?.comments ?? [],
550
+ savedAt: draft?.savedAt ?? new Date().toISOString(),
551
+ };
557
552
  }
558
- async function waitForParkedReviewSubmit(submitted) {
553
+ async function waitForParkedReviewSubmit(submitted, signal) {
559
554
  process.stderr.write('\nhumanloop: browser review handoff is active.\n' +
560
555
  ' The terminal editor is parked; the browser is the editing authority.\n' +
561
556
  ' Press w to take back into nvim, or Ctrl+C to exit with an unsubmitted draft.\n\n');
562
- if (!process.stdin.isTTY) {
563
- const result = await submitted;
564
- return { type: 'submitted', result };
565
- }
566
557
  return new Promise((resolveAction) => {
567
558
  const stdin = process.stdin;
559
+ const interactive = stdin.isTTY;
568
560
  const wasRaw = stdin.isRaw;
569
561
  let settled = false;
570
562
  const cleanup = () => {
563
+ signal?.removeEventListener('abort', onAbort);
564
+ if (!interactive)
565
+ return;
571
566
  stdin.off('data', onData);
572
567
  if (stdin.setRawMode)
573
568
  stdin.setRawMode(wasRaw);
@@ -580,6 +575,7 @@ async function waitForParkedReviewSubmit(submitted) {
580
575
  cleanup();
581
576
  resolveAction(action);
582
577
  };
578
+ const onAbort = () => finish({ type: 'cancel' });
583
579
  const onData = (chunk) => {
584
580
  const text = chunk.toString('utf8');
585
581
  if (text === 'w' || text === 'W')
@@ -587,9 +583,14 @@ async function waitForParkedReviewSubmit(submitted) {
587
583
  if (text === '\u0003')
588
584
  finish({ type: 'cancel' });
589
585
  };
590
- stdin.setRawMode(true);
591
- stdin.resume();
592
- stdin.on('data', onData);
586
+ if (interactive) {
587
+ stdin.setRawMode(true);
588
+ stdin.resume();
589
+ stdin.on('data', onData);
590
+ }
591
+ signal?.addEventListener('abort', onAbort, { once: true });
592
+ if (signal?.aborted)
593
+ onAbort();
593
594
  submitted.then((result) => finish({ type: 'submitted', result }), () => finish({ type: 'cancel' }));
594
595
  });
595
596
  }
@@ -600,9 +601,8 @@ async function stopReviewServer(handle) {
600
601
  /**
601
602
  * Open a markdown file in a clean, read-only Neovim/Vim review session. The
602
603
  * human anchors comments to source lines/selections with native vim motions
603
- * and quits to submit. Blocks until the editor exits, then finalizes and
604
- * returns the feedback. Autosaved continuously so a kill is recoverable and
605
- * the next run resumes.
604
+ * and explicitly submits a proposal. Blocks until the editor exits. Autosaved
605
+ * drafts survive exit; canonical ticket finalization belongs to the controller.
606
606
  */
607
607
  export async function launchReview(file, opts) {
608
608
  const absFile = resolve(file);
@@ -611,19 +611,19 @@ export async function launchReview(file, opts) {
611
611
  }
612
612
  const outPath = resolve(opts.output);
613
613
  const bin = resolveEditor(opts.editor);
614
- // When the async kickoff passes its job dir, source the review.vim the parent
615
- // already wrote there; otherwise mint a private temp dir as before.
614
+ // Reuse the controller-owned job directory when supplied; otherwise create a private directory.
616
615
  const dir = opts.jobDir ?? mkdtempSync(join(tmpdir(), 'hl-review-'));
617
616
  const initPath = join(dir, 'review.vim');
618
617
  if (!existsSync(initPath))
619
618
  writeFileSync(initPath, reviewVimscript());
620
619
  const handoffFlagPath = join(dir, 'browser-handoff.flag');
620
+ const submitFlagPath = join(dir, 'review-submit.flag');
621
+ const closeFlagPath = join(dir, 'review-close.flag');
621
622
  // `-u NONE`: do NOT load the user's init.lua / LazyVim / plugins / keymaps.
622
623
  // Default runtimepath still includes the config dir (for the gloam
623
624
  // colorscheme) and the site dir (for the treesitter markdown parser), so the
624
625
  // review layer pulls in ONLY the colorscheme + treesitter styling itself.
625
626
  const editorArgs = ['-u', 'NONE', '-n', '-i', 'NONE', absFile, '-c', `source ${initPath}`];
626
- const inTmux = !!process.env.TMUX && !opts.noTmux;
627
627
  async function runEditor(reviewUrl) {
628
628
  const env = {
629
629
  ...process.env,
@@ -631,73 +631,58 @@ export async function launchReview(file, opts) {
631
631
  HL_SOURCE: absFile,
632
632
  HL_REVIEW_URL: reviewUrl,
633
633
  HL_HANDOFF_FLAG: handoffFlagPath,
634
- ...(opts.submitFlagPath ? { HL_SUBMIT_FLAG: opts.submitFlagPath } : {}),
634
+ HL_SUBMIT_FLAG: submitFlagPath,
635
+ HL_CLOSE_FLAG: closeFlagPath,
635
636
  };
636
- process.stderr.write(`\nhumanloop: opening "${absFile}" for review in ${bin}` +
637
- (inTmux ? ' (tmux pane).\n' : '.\n') +
638
- ` Answers : ${outPath}\n` +
637
+ process.stderr.write(`\nhumanloop: opening "${absFile}" for review in ${bin}.\n` +
638
+ ` Draft : ${outPath}\n` +
639
639
  ` Browser : available after <Space>w hands off (or :HLBrowser)\n` +
640
640
  ` Keys : <Space>c comment · <Space>l list · <Space>u undo · <Space>s submit & quit · <Space>w browser (or :HLComment/:HLSubmit/:HLBrowser)\n` +
641
641
  ` Status : BLOCKING — waiting for you to finish the review.\n\n`);
642
- if (inTmux) {
643
- // `exec env …` so the editor replaces the shell and becomes the pane's
644
- // process (so tmux `#{pane_current_command}` is `nvim`, not the shell).
645
- const envPairs = [
646
- `HL_OUTPUT=${shellQuote(outPath)}`,
647
- `HL_SOURCE=${shellQuote(absFile)}`,
648
- `HL_REVIEW_URL=${shellQuote(reviewUrl)}`,
649
- `HL_HANDOFF_FLAG=${shellQuote(handoffFlagPath)}`,
650
- ...(opts.submitFlagPath ? [`HL_SUBMIT_FLAG=${shellQuote(opts.submitFlagPath)}`] : []),
651
- ];
652
- const paneCmd = [
653
- 'exec',
654
- 'env',
655
- ...envPairs,
656
- shellQuote(bin),
657
- ...editorArgs.map(shellQuote),
658
- ].join(' ');
659
- try {
660
- if (opts.tmuxPopup) {
661
- const w = opts.popupSize ? opts.popupSize.w : '90%';
662
- const h = opts.popupSize ? opts.popupSize.h : '90%';
663
- execFileSync('tmux', ['display-popup', '-E', '-w', w, '-h', h, paneCmd], { stdio: 'inherit' });
664
- }
665
- else {
666
- await runInTmuxPane(paneCmd);
667
- }
668
- }
669
- catch (err) {
670
- process.stderr.write(`tmux dispatch failed, running in current terminal: ${err instanceof Error ? err.message : String(err)}\n`);
671
- await runInCurrentTerminal(bin, editorArgs, env);
672
- }
673
- }
674
- else {
675
- await runInCurrentTerminal(bin, editorArgs, env);
676
- }
642
+ await runInCurrentTerminal(bin, editorArgs, env, opts.signal);
677
643
  }
678
644
  while (true) {
679
645
  clearFlag(handoffFlagPath);
646
+ clearFlag(submitFlagPath);
647
+ clearFlag(closeFlagPath);
680
648
  let resolveSubmitted;
681
649
  const submitted = new Promise((resolveSubmittedPromise) => {
682
650
  resolveSubmitted = resolveSubmittedPromise;
683
651
  });
652
+ if (opts.signal?.aborted)
653
+ return draftFeedback(outPath, absFile);
684
654
  const server = await startReviewWebServer({
685
655
  jobDir: dir,
686
656
  file: absFile,
687
657
  output: outPath,
688
- submitFlagPath: opts.submitFlagPath,
658
+ submitFlagPath,
689
659
  onSubmit: (result) => resolveSubmitted(result),
690
660
  });
661
+ if (opts.signal?.aborted) {
662
+ await stopReviewServer(server);
663
+ return draftFeedback(outPath, absFile);
664
+ }
691
665
  try {
692
666
  await runEditor(server.url);
667
+ // Option/Alt+I close request wins over every other exit outcome: save the
668
+ // draft, leave the ticket pending, and tell the controller to dismiss the
669
+ // whole inbox. Checked before handoff/submit so it is never misread.
670
+ if (existsSync(closeFlagPath)) {
671
+ await stopReviewServer(server);
672
+ clearFlag(closeFlagPath);
673
+ await opts.onClose?.();
674
+ return draftFeedback(outPath, absFile);
675
+ }
693
676
  if (existsSync(handoffFlagPath)) {
694
677
  server.activate();
695
678
  process.stderr.write(`humanloop: browser review handoff active — ${server.url}\n`);
696
679
  openBrowser(server.url);
697
- const action = await waitForParkedReviewSubmit(submitted);
680
+ const action = await waitForParkedReviewSubmit(submitted, opts.signal);
698
681
  if (action.type === 'submitted') {
699
682
  await stopReviewServer(server);
700
683
  clearFlag(handoffFlagPath);
684
+ if (!opts.signal?.aborted)
685
+ await opts.onPropose?.(action.result);
701
686
  return action.result;
702
687
  }
703
688
  if (action.type === 'take-back') {
@@ -709,11 +694,16 @@ export async function launchReview(file, opts) {
709
694
  }
710
695
  await stopReviewServer(server);
711
696
  clearFlag(handoffFlagPath);
712
- return finalizeReviewOutput(outPath, absFile, false);
697
+ return draftFeedback(outPath, absFile);
713
698
  }
714
699
  await stopReviewServer(server);
715
- const didSubmit = opts.submitFlagPath ? existsSync(opts.submitFlagPath) : true;
716
- return finalizeReviewOutput(outPath, absFile, didSubmit);
700
+ if (existsSync(submitFlagPath)) {
701
+ const proposal = proposedFeedback(outPath, absFile);
702
+ if (!opts.signal?.aborted)
703
+ await opts.onPropose?.(proposal);
704
+ return proposal;
705
+ }
706
+ return draftFeedback(outPath, absFile);
717
707
  }
718
708
  catch (err) {
719
709
  await stopReviewServer(server);
@@ -0,0 +1,23 @@
1
+ export interface TicketClaim {
2
+ token: string;
3
+ host: string;
4
+ pid: number;
5
+ claimedAt: string;
6
+ heartbeatAt: string;
7
+ tmuxClient?: string;
8
+ }
9
+ export interface ClaimOptions {
10
+ host?: string;
11
+ pid?: number;
12
+ tmuxClient?: string;
13
+ now?: Date;
14
+ }
15
+ export declare function readTicketClaim(dir: string): TicketClaim | null;
16
+ export declare function isStaleClaim(claim: TicketClaim, now?: number, localHost?: string): boolean;
17
+ /** Acquire a visible claim only for a still-pending ticket. Malformed crash artifacts are stale claims. */
18
+ export declare function claimTicket(dir: string, opts?: ClaimOptions): TicketClaim | null;
19
+ export declare function heartbeatClaim(dir: string, token: string, now?: Date): boolean;
20
+ export declare function releaseClaimLocked(dir: string, token: string): boolean;
21
+ export declare function releaseClaim(dir: string, token: string): boolean;
22
+ export declare function claimExists(dir: string): boolean;
23
+ export declare function withTicketLock<T>(dir: string, operation: () => T): T;
@@ -0,0 +1,67 @@
1
+ import { existsSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { hostname } from 'node:os';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { claimPath, deckPath, readJson, responsePath, reviewPath, withExclusiveDirectoryLock } from './convention.js';
5
+ const REMOTE_STALE_MS = 30_000;
6
+ function parseClaim(raw) {
7
+ if (typeof raw !== 'object' || raw === null)
8
+ return null;
9
+ const claim = raw;
10
+ if (typeof claim.token !== 'string' || typeof claim.host !== 'string' || !Number.isInteger(claim.pid) || typeof claim.claimedAt !== 'string' || typeof claim.heartbeatAt !== 'string')
11
+ return null;
12
+ return { token: claim.token, host: claim.host, pid: claim.pid, claimedAt: claim.claimedAt, heartbeatAt: claim.heartbeatAt, ...(typeof claim.tmuxClient === 'string' ? { tmuxClient: claim.tmuxClient } : {}) };
13
+ }
14
+ export function readTicketClaim(dir) { return parseClaim(readJson(claimPath(dir))); }
15
+ function isLiveLocalPid(pid) { try {
16
+ process.kill(pid, 0);
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ } }
22
+ export function isStaleClaim(claim, now = Date.now(), localHost = hostname()) {
23
+ if (claim.host === localHost)
24
+ return !isLiveLocalPid(claim.pid);
25
+ const heartbeat = Date.parse(claim.heartbeatAt);
26
+ return !Number.isFinite(heartbeat) || now - heartbeat > REMOTE_STALE_MS;
27
+ }
28
+ /** Acquire a visible claim only for a still-pending ticket. Malformed crash artifacts are stale claims. */
29
+ export function claimTicket(dir, opts = {}) {
30
+ return withExclusiveDirectoryLock(`${dir}/.ticket-lock`, () => {
31
+ if (existsSync(responsePath(dir)) || (!existsSync(deckPath(dir)) && !existsSync(reviewPath(dir))))
32
+ return null;
33
+ const path = claimPath(dir);
34
+ const existing = readTicketClaim(dir);
35
+ if (existing !== null && !isStaleClaim(existing, Date.now(), opts.host ?? hostname()))
36
+ return null;
37
+ if (existsSync(path))
38
+ unlinkSync(path);
39
+ const host = opts.host ?? hostname();
40
+ const pid = opts.pid ?? process.pid;
41
+ const now = (opts.now ?? new Date()).toISOString();
42
+ const claim = { token: randomUUID(), host, pid, claimedAt: now, heartbeatAt: now, ...(opts.tmuxClient ? { tmuxClient: opts.tmuxClient } : {}) };
43
+ writeFileSync(path, `${JSON.stringify(claim)}\n`, { flag: 'wx', mode: 0o600 });
44
+ return claim;
45
+ });
46
+ }
47
+ export function heartbeatClaim(dir, token, now = new Date()) {
48
+ return withExclusiveDirectoryLock(`${dir}/.ticket-lock`, () => {
49
+ const claim = readTicketClaim(dir);
50
+ if (claim === null || claim.token !== token)
51
+ return false;
52
+ const temp = `${claimPath(dir)}.${token}.heartbeat`;
53
+ writeFileSync(temp, `${JSON.stringify({ ...claim, heartbeatAt: now.toISOString() })}\n`, { flag: 'wx', mode: 0o600 });
54
+ renameSync(temp, claimPath(dir));
55
+ return true;
56
+ });
57
+ }
58
+ export function releaseClaimLocked(dir, token) {
59
+ const claim = readTicketClaim(dir);
60
+ if (claim === null || claim.token !== token)
61
+ return false;
62
+ unlinkSync(claimPath(dir));
63
+ return true;
64
+ }
65
+ export function releaseClaim(dir, token) { return withExclusiveDirectoryLock(`${dir}/.ticket-lock`, () => releaseClaimLocked(dir, token)); }
66
+ export function claimExists(dir) { return existsSync(claimPath(dir)); }
67
+ export function withTicketLock(dir, operation) { return withExclusiveDirectoryLock(`${dir}/.ticket-lock`, operation); }
@@ -0,0 +1,4 @@
1
+ /** Deliver one canonical result. It is safe to call repeatedly after crashes. */
2
+ export declare function dispatchCompletion(root: string, dir: string): Promise<'delivered' | 'pending' | 'none'>;
3
+ /** Event-driven startup/root-change reconciliation for results lacking an ack. */
4
+ export declare function reconcileCompletions(root: string): Promise<void>;