@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.
- package/dist/api.d.ts +4 -8
- package/dist/api.js +6 -29
- package/dist/browser/server.d.ts +3 -3
- package/dist/browser/server.js +22 -25
- package/dist/cli.js +131 -1114
- package/dist/editor/feedback.d.ts +9 -0
- package/dist/editor/feedback.js +23 -0
- package/dist/editor/review.d.ts +12 -30
- package/dist/editor/review.js +109 -119
- package/dist/inbox/claim.d.ts +23 -0
- package/dist/inbox/claim.js +67 -0
- package/dist/inbox/completion.d.ts +4 -0
- package/dist/inbox/completion.js +107 -0
- package/dist/inbox/controller.d.ts +74 -0
- package/dist/inbox/controller.js +457 -0
- package/dist/inbox/convention.d.ts +15 -10
- package/dist/inbox/convention.js +149 -79
- package/dist/inbox/deck-adapter.d.ts +27 -0
- package/dist/inbox/deck-adapter.js +57 -0
- package/dist/inbox/deck-schema.d.ts +18 -14
- package/dist/inbox/deck-schema.js +59 -117
- package/dist/inbox/layout.d.ts +8 -0
- package/dist/inbox/layout.js +9 -0
- package/dist/inbox/registry.d.ts +29 -0
- package/dist/inbox/registry.js +97 -0
- package/dist/inbox/review-adapter.d.ts +24 -0
- package/dist/inbox/review-adapter.js +57 -0
- package/dist/inbox/scan.d.ts +3 -2
- package/dist/inbox/scan.js +71 -43
- package/dist/inbox/tickets.d.ts +63 -0
- package/dist/inbox/tickets.js +247 -0
- package/dist/inbox/tui.d.ts +3 -6
- package/dist/inbox/tui.js +24 -112
- package/dist/index.d.ts +12 -3
- package/dist/index.js +8 -2
- package/dist/render/termrender.d.ts +5 -0
- package/dist/render/termrender.js +63 -4
- package/dist/summary.d.ts +2 -3
- package/dist/summary.js +2 -3
- package/dist/surfaces/inbox-popup.d.ts +2 -0
- package/dist/surfaces/inbox-popup.js +32 -0
- package/dist/tui/app.js +13 -0
- package/dist/tui/tmux.d.ts +30 -7
- package/dist/tui/tmux.js +190 -66
- package/dist/types.d.ts +68 -7
- 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;
|
package/dist/editor/feedback.js
CHANGED
|
@@ -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, '');
|
package/dist/editor/review.d.ts
CHANGED
|
@@ -1,35 +1,18 @@
|
|
|
1
1
|
import type { FeedbackResult } from '../types.js';
|
|
2
2
|
export interface ReviewOptions {
|
|
3
|
-
/** Where the
|
|
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
|
-
/**
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
47
|
-
*
|
|
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>;
|
package/dist/editor/review.js
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
1
|
-
import { spawn, spawnSync
|
|
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 {
|
|
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,'
|
|
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
|
|
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!
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
542
|
-
|
|
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
|
|
549
|
-
|
|
550
|
-
return stored?.comments ?? [];
|
|
540
|
+
function proposedFeedback(path, absFile) {
|
|
541
|
+
return buildFinalFeedbackResult(absFile, readReviewDraft(path)?.comments ?? []);
|
|
551
542
|
}
|
|
552
|
-
function
|
|
553
|
-
const
|
|
554
|
-
return
|
|
555
|
-
|
|
556
|
-
:
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
|
604
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
634
|
+
HL_SUBMIT_FLAG: submitFlagPath,
|
|
635
|
+
HL_CLOSE_FLAG: closeFlagPath,
|
|
635
636
|
};
|
|
636
|
-
process.stderr.write(`\nhumanloop: opening "${absFile}" for review in ${bin}` +
|
|
637
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
697
|
+
return draftFeedback(outPath, absFile);
|
|
713
698
|
}
|
|
714
699
|
await stopReviewServer(server);
|
|
715
|
-
|
|
716
|
-
|
|
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>;
|