@crouton-kit/humanloop 0.3.34 → 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/cli.js +8 -3
- package/dist/editor/review.d.ts +4 -0
- package/dist/editor/review.js +30 -1
- package/dist/inbox/completion.js +10 -3
- package/dist/inbox/controller.d.ts +17 -0
- package/dist/inbox/controller.js +156 -10
- package/dist/inbox/review-adapter.d.ts +2 -0
- package/dist/inbox/review-adapter.js +1 -0
- package/dist/inbox/tui.js +1 -1
- package/dist/render/termrender.d.ts +5 -0
- package/dist/render/termrender.js +63 -4
- package/dist/tui/tmux.js +15 -4
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -34,7 +34,8 @@ function fail(error) {
|
|
|
34
34
|
emit({ error: 'bad_input', message, next: `Run ${help} for usage.` });
|
|
35
35
|
process.exit(1);
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
/** Explicit --root values filter the scan; no --root (an empty array from commander) falls through to the registered-roots fallback in scanInbox/the controller, so it must be undefined, not []. */
|
|
38
|
+
function roots(values) { return values && values.length > 0 ? values.map((root) => resolve(root)) : undefined; }
|
|
38
39
|
/** Resolve an explicit --root to its existing registration; an unregistered root is an error, never a silent humanloop-owned registration. */
|
|
39
40
|
function requireRegisteredRoot(root) {
|
|
40
41
|
const abs = resolve(root);
|
|
@@ -51,9 +52,13 @@ inbox.command('open').description('Open the inbox controller in this human TTY.'
|
|
|
51
52
|
fail('hl inbox open requires an interactive TTY; use hl inbox list for read-only output');
|
|
52
53
|
await openInboxPopup(options.controlSocket, roots(options.root));
|
|
53
54
|
});
|
|
54
|
-
inbox.command('toggle').description('Toggle the inbox popup for one tmux client.').option('--tmux-socket <path>').option('--tmux-client <name>').option('--target-pane <pane>').action(async (options) => {
|
|
55
|
+
inbox.command('toggle').description('Toggle the inbox popup for one tmux client.').option('--tmux-socket <path>').option('--tmux-client <name>').option('--target-pane <pane>').option('--quiet', 'suppress result JSON on success (the tmux binding uses this so run-shell -b output never overlays the pane)').action(async (options) => {
|
|
55
56
|
const result = await toggleInboxPopup({ socket: options.tmuxSocket, client: options.tmuxClient, targetPane: options.targetPane });
|
|
56
|
-
|
|
57
|
+
// Under the `run-shell -b` binding, any stdout becomes a tmux view-mode overlay on the active
|
|
58
|
+
// pane. The opened/closed popup is its own feedback, so stay silent on every non-failure result;
|
|
59
|
+
// a genuine `failed` still prints so a broken binding is not invisible.
|
|
60
|
+
if (!options.quiet || result === 'failed')
|
|
61
|
+
emit({ result, next: result === 'not_in_tmux' ? 'Run hl inbox open in a human terminal.' : undefined });
|
|
57
62
|
process.exit(result === 'failed' || result === 'ambiguous_client' ? 1 : 0);
|
|
58
63
|
});
|
|
59
64
|
inbox.command('list').description('Print pending tickets newest first as JSON.').option('--root <path>', 'filter to a root', (value, prior) => [...prior, value], []).action((options) => emit(scanInbox(roots(options.root))));
|
package/dist/editor/review.d.ts
CHANGED
|
@@ -6,6 +6,10 @@ export interface ReviewOptions {
|
|
|
6
6
|
editor?: string;
|
|
7
7
|
/** Called exactly once when the human explicitly submits a final proposal. */
|
|
8
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;
|
|
9
13
|
/** Controller cancellation closes the editor/browser handoff without proposing a result. */
|
|
10
14
|
signal?: AbortSignal;
|
|
11
15
|
/** Reuse this controller-owned directory for `review.vim`; an existing script is left untouched. */
|
package/dist/editor/review.js
CHANGED
|
@@ -309,6 +309,17 @@ export function reviewVimscript() {
|
|
|
309
309
|
` qa!`,
|
|
310
310
|
`endfunction`,
|
|
311
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
|
+
``,
|
|
312
323
|
`function! s:HandOff() abort`,
|
|
313
324
|
` call s:Save()`,
|
|
314
325
|
` if g:hl_handoff_flag ==# ''`,
|
|
@@ -324,7 +335,13 @@ export function reviewVimscript() {
|
|
|
324
335
|
`command! HLUndo call <SID>Undo()`,
|
|
325
336
|
`command! HLSubmit call <SID>Submit()`,
|
|
326
337
|
`command! HLBrowser call <SID>HandOff()`,
|
|
327
|
-
`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'`,
|
|
328
345
|
``,
|
|
329
346
|
`" Highlights are (re)applied after any colorscheme so the user's theme`,
|
|
330
347
|
`" (e.g. gloam) loads first, then our anchor highlight sits on top.`,
|
|
@@ -601,6 +618,7 @@ export async function launchReview(file, opts) {
|
|
|
601
618
|
writeFileSync(initPath, reviewVimscript());
|
|
602
619
|
const handoffFlagPath = join(dir, 'browser-handoff.flag');
|
|
603
620
|
const submitFlagPath = join(dir, 'review-submit.flag');
|
|
621
|
+
const closeFlagPath = join(dir, 'review-close.flag');
|
|
604
622
|
// `-u NONE`: do NOT load the user's init.lua / LazyVim / plugins / keymaps.
|
|
605
623
|
// Default runtimepath still includes the config dir (for the gloam
|
|
606
624
|
// colorscheme) and the site dir (for the treesitter markdown parser), so the
|
|
@@ -614,6 +632,7 @@ export async function launchReview(file, opts) {
|
|
|
614
632
|
HL_REVIEW_URL: reviewUrl,
|
|
615
633
|
HL_HANDOFF_FLAG: handoffFlagPath,
|
|
616
634
|
HL_SUBMIT_FLAG: submitFlagPath,
|
|
635
|
+
HL_CLOSE_FLAG: closeFlagPath,
|
|
617
636
|
};
|
|
618
637
|
process.stderr.write(`\nhumanloop: opening "${absFile}" for review in ${bin}.\n` +
|
|
619
638
|
` Draft : ${outPath}\n` +
|
|
@@ -625,6 +644,7 @@ export async function launchReview(file, opts) {
|
|
|
625
644
|
while (true) {
|
|
626
645
|
clearFlag(handoffFlagPath);
|
|
627
646
|
clearFlag(submitFlagPath);
|
|
647
|
+
clearFlag(closeFlagPath);
|
|
628
648
|
let resolveSubmitted;
|
|
629
649
|
const submitted = new Promise((resolveSubmittedPromise) => {
|
|
630
650
|
resolveSubmitted = resolveSubmittedPromise;
|
|
@@ -644,6 +664,15 @@ export async function launchReview(file, opts) {
|
|
|
644
664
|
}
|
|
645
665
|
try {
|
|
646
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
|
+
}
|
|
647
676
|
if (existsSync(handoffFlagPath)) {
|
|
648
677
|
server.activate();
|
|
649
678
|
process.stderr.write(`humanloop: browser review handoff active — ${server.url}\n`);
|
package/dist/inbox/completion.js
CHANGED
|
@@ -14,18 +14,25 @@ function eventFor(root, dir, result) {
|
|
|
14
14
|
}
|
|
15
15
|
async function runHandler(command, args, event) {
|
|
16
16
|
await new Promise((resolvePromise, rejectPromise) => {
|
|
17
|
-
|
|
17
|
+
// stdout stays ignored (a handler acknowledges by exit code, never stdout),
|
|
18
|
+
// but stderr is captured so a nonzero exit surfaces the handler's own
|
|
19
|
+
// diagnostics in the delivery-error record instead of a bare exit code.
|
|
20
|
+
const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
21
|
+
let stderr = '';
|
|
22
|
+
child.stderr?.on('data', (chunk) => { stderr += chunk; if (stderr.length > 8192)
|
|
23
|
+
stderr = stderr.slice(-8192); });
|
|
18
24
|
let timedOut = false;
|
|
19
25
|
const timeout = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 30_000);
|
|
20
26
|
child.once('error', (error) => { clearTimeout(timeout); rejectPromise(error); });
|
|
21
27
|
child.once('exit', (code, signal) => {
|
|
22
28
|
clearTimeout(timeout);
|
|
29
|
+
const detail = stderr.trim() === '' ? '' : `: ${stderr.trim()}`;
|
|
23
30
|
if (timedOut)
|
|
24
|
-
rejectPromise(new Error(
|
|
31
|
+
rejectPromise(new Error(`completion handler timed out after 30 seconds${detail}`));
|
|
25
32
|
else if (code === 0)
|
|
26
33
|
resolvePromise();
|
|
27
34
|
else
|
|
28
|
-
rejectPromise(new Error(`completion handler failed (${signal ?? code ?? 'unknown'})`));
|
|
35
|
+
rejectPromise(new Error(`completion handler failed (${signal ?? code ?? 'unknown'})${detail}`));
|
|
29
36
|
});
|
|
30
37
|
child.stdin.end(`${JSON.stringify(event)}\n`);
|
|
31
38
|
});
|
|
@@ -18,7 +18,12 @@ export declare class InboxController {
|
|
|
18
18
|
private selectedIndex;
|
|
19
19
|
private screen;
|
|
20
20
|
private adapter;
|
|
21
|
+
private reviewAdapter;
|
|
21
22
|
private claim;
|
|
23
|
+
private reconciling;
|
|
24
|
+
private suspended;
|
|
25
|
+
private submittingDir;
|
|
26
|
+
private stdinListener;
|
|
22
27
|
private cols;
|
|
23
28
|
private rows;
|
|
24
29
|
private frame;
|
|
@@ -41,10 +46,22 @@ export declare class InboxController {
|
|
|
41
46
|
render(): string[];
|
|
42
47
|
handleKey(input: string, key: Key): void;
|
|
43
48
|
activate(): void;
|
|
49
|
+
/** Claim the review, hand the whole popup TTY to the native editor, then
|
|
50
|
+
* converge to the on-disk outcome when it exits. Draft/final ownership stays
|
|
51
|
+
* in ReviewAdapter; the controller only owns the terminal handoff. */
|
|
52
|
+
private activateReview;
|
|
44
53
|
reloadSelectedDeck(): void;
|
|
45
54
|
close(): void;
|
|
46
55
|
run(): Promise<void>;
|
|
47
56
|
private complete;
|
|
57
|
+
/** Give the raw TTY to a child process (native review editor). */
|
|
58
|
+
private suspendForChild;
|
|
59
|
+
/** Retake the TTY after the child exits and force a full repaint. */
|
|
60
|
+
private resumeAfterChild;
|
|
61
|
+
private resolvedRoots;
|
|
62
|
+
/** Owner-boundary reconciliation for resolved results still lacking an ack.
|
|
63
|
+
* Guarded so overlapping fs events cannot stack concurrent scans. */
|
|
64
|
+
private reconcileRoots;
|
|
48
65
|
private leaveDetail;
|
|
49
66
|
private select;
|
|
50
67
|
private detailSize;
|
package/dist/inbox/controller.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { watch } from 'node:fs';
|
|
1
|
+
import { readFileSync, watch } from 'node:fs';
|
|
2
2
|
import { getTerminalSize, parseKeypress, restoreTerminal, setupTerminal } from '../tui/terminal.js';
|
|
3
3
|
import { diffFrame } from '../tui/render.js';
|
|
4
|
+
import { renderMarkdown } from '../render/termrender.js';
|
|
4
5
|
import { BOLD, CYAN, DIM, RESET, YELLOW } from '../tui/ansi.js';
|
|
5
6
|
import { buildInboxLines } from './tui.js';
|
|
6
7
|
import { inboxLayout } from './layout.js';
|
|
@@ -8,8 +9,10 @@ import { scanInbox } from './scan.js';
|
|
|
8
9
|
import { inboxRootsDirectory, listInboxRoots } from './registry.js';
|
|
9
10
|
import { claimTicket, heartbeatClaim, releaseClaim } from './claim.js';
|
|
10
11
|
import { completeDeck, readTicketResult } from './tickets.js';
|
|
11
|
-
import {
|
|
12
|
+
import { reconcileCompletions } from './completion.js';
|
|
13
|
+
import { clearProgress, deckPath, progressPath, readJson, reviewPath } from './convention.js';
|
|
12
14
|
import { DeckAdapter } from './deck-adapter.js';
|
|
15
|
+
import { ReviewAdapter } from './review-adapter.js';
|
|
13
16
|
/** One terminal owner for scanning, stable selection, claims, and frame diffs. */
|
|
14
17
|
export class InboxController {
|
|
15
18
|
options;
|
|
@@ -20,7 +23,12 @@ export class InboxController {
|
|
|
20
23
|
selectedIndex = 0;
|
|
21
24
|
screen = 'list';
|
|
22
25
|
adapter;
|
|
26
|
+
reviewAdapter;
|
|
23
27
|
claim;
|
|
28
|
+
reconciling = false;
|
|
29
|
+
suspended = false;
|
|
30
|
+
submittingDir;
|
|
31
|
+
stdinListener;
|
|
24
32
|
cols;
|
|
25
33
|
rows;
|
|
26
34
|
frame = [];
|
|
@@ -56,7 +64,11 @@ export class InboxController {
|
|
|
56
64
|
const canceled = readTicketResult(priorDir)?.kind === 'canceled';
|
|
57
65
|
if (canceled)
|
|
58
66
|
clearProgress(priorDir);
|
|
59
|
-
|
|
67
|
+
// Suppress the "resolved elsewhere" banner when WE are the ones resolving
|
|
68
|
+
// this ticket: the response.json we just published trips the root watch
|
|
69
|
+
// mid-submit, and that is our own completion, not an external event.
|
|
70
|
+
if (priorDir !== this.submittingDir)
|
|
71
|
+
this.status = canceled ? 'canceled by requester' : 'ticket resolved elsewhere';
|
|
60
72
|
this.leaveDetail();
|
|
61
73
|
}
|
|
62
74
|
if (this.items.length === 0) {
|
|
@@ -94,7 +106,11 @@ export class InboxController {
|
|
|
94
106
|
return this.withStatus(lines);
|
|
95
107
|
}
|
|
96
108
|
handleKey(input, key) {
|
|
97
|
-
|
|
109
|
+
// Option/Alt+I (M-i) and Ctrl-C request graceful close from ANY controller
|
|
110
|
+
// mode. A root-table binding cannot fire while the popup grabs client input,
|
|
111
|
+
// so the close-from-open path must live here — checked before adapter
|
|
112
|
+
// forwarding so it works in active deck freetext as well as the list.
|
|
113
|
+
if ((key.ctrl && input === 'c') || isToggleCloseChord(input)) {
|
|
98
114
|
this.close();
|
|
99
115
|
return;
|
|
100
116
|
}
|
|
@@ -117,7 +133,13 @@ export class InboxController {
|
|
|
117
133
|
}
|
|
118
134
|
activate() {
|
|
119
135
|
const item = this.items[this.selectedIndex];
|
|
120
|
-
if (item === undefined
|
|
136
|
+
if (item === undefined)
|
|
137
|
+
return;
|
|
138
|
+
if (item.kind === 'review') {
|
|
139
|
+
void this.activateReview(item);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (item.kind !== 'deck')
|
|
121
143
|
return;
|
|
122
144
|
const claim = claimTicket(item.dir);
|
|
123
145
|
if (claim === null) {
|
|
@@ -148,11 +170,55 @@ export class InboxController {
|
|
|
148
170
|
});
|
|
149
171
|
this.watchSelected(item.dir);
|
|
150
172
|
}
|
|
173
|
+
/** Claim the review, hand the whole popup TTY to the native editor, then
|
|
174
|
+
* converge to the on-disk outcome when it exits. Draft/final ownership stays
|
|
175
|
+
* in ReviewAdapter; the controller only owns the terminal handoff. */
|
|
176
|
+
async activateReview(item) {
|
|
177
|
+
const claim = claimTicket(item.dir);
|
|
178
|
+
if (claim === null) {
|
|
179
|
+
this.status = 'ticket is being edited by another inbox';
|
|
180
|
+
this.repaint();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const descriptor = readJson(reviewPath(item.dir));
|
|
184
|
+
if (descriptor === null) {
|
|
185
|
+
releaseClaim(item.dir, claim.token);
|
|
186
|
+
this.invalidate();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
let closeRequested = false;
|
|
190
|
+
this.reviewAdapter = new ReviewAdapter({ dir: item.dir, descriptor, claim, onClose: () => { closeRequested = true; } });
|
|
191
|
+
this.suspendForChild();
|
|
192
|
+
try {
|
|
193
|
+
await this.reviewAdapter.start();
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
this.status = error instanceof Error ? error.message : String(error);
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
this.reviewAdapter = undefined;
|
|
200
|
+
// M-i inside native review is a graceful close of the WHOLE inbox: the
|
|
201
|
+
// adapter already saved the draft and released the claim, so just tear the
|
|
202
|
+
// controller down instead of returning to the list. The ticket stays
|
|
203
|
+
// pending because no submit flag was written.
|
|
204
|
+
if (closeRequested) {
|
|
205
|
+
this.suspended = false;
|
|
206
|
+
this.close();
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
this.resumeAfterChild();
|
|
210
|
+
this.rescan();
|
|
211
|
+
this.reconcileRoots();
|
|
212
|
+
this.repaint(true);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
151
216
|
reloadSelectedDeck() { this.adapter?.reload(); this.repaint(); }
|
|
152
217
|
close() {
|
|
153
218
|
if (this.closed)
|
|
154
219
|
return;
|
|
155
220
|
this.closed = true;
|
|
221
|
+
void this.reviewAdapter?.stop();
|
|
156
222
|
this.leaveDetail();
|
|
157
223
|
for (const watcher of this.watchers)
|
|
158
224
|
watcher.close();
|
|
@@ -165,6 +231,9 @@ export class InboxController {
|
|
|
165
231
|
setupTerminal();
|
|
166
232
|
this.running = true;
|
|
167
233
|
this.watchRoots();
|
|
234
|
+
// Close the crash window between an earlier result publication and its
|
|
235
|
+
// handler launch: dispatch every resolved-but-undelivered ticket now.
|
|
236
|
+
this.reconcileRoots();
|
|
168
237
|
this.repaint(true);
|
|
169
238
|
const heartbeat = setInterval(() => {
|
|
170
239
|
if (this.claim !== undefined)
|
|
@@ -177,6 +246,7 @@ export class InboxController {
|
|
|
177
246
|
if (this.closed)
|
|
178
247
|
finish();
|
|
179
248
|
};
|
|
249
|
+
this.stdinListener = onData;
|
|
180
250
|
const onResize = () => this.resize();
|
|
181
251
|
let done = false;
|
|
182
252
|
this.finishRun = () => {
|
|
@@ -184,6 +254,7 @@ export class InboxController {
|
|
|
184
254
|
return;
|
|
185
255
|
done = true;
|
|
186
256
|
process.stdin.removeListener('data', onData);
|
|
257
|
+
this.stdinListener = undefined;
|
|
187
258
|
process.stdout.removeListener('resize', onResize);
|
|
188
259
|
clearInterval(heartbeat);
|
|
189
260
|
this.running = false;
|
|
@@ -200,17 +271,62 @@ export class InboxController {
|
|
|
200
271
|
const claim = this.claim;
|
|
201
272
|
if (claim === undefined)
|
|
202
273
|
return;
|
|
274
|
+
this.submittingDir = claim.dir;
|
|
203
275
|
try {
|
|
204
276
|
await this.finishDeck(claim.dir, responses, claim.token);
|
|
205
277
|
}
|
|
206
278
|
catch (error) {
|
|
279
|
+
this.submittingDir = undefined;
|
|
207
280
|
this.status = error instanceof Error ? error.message : String(error);
|
|
208
281
|
return;
|
|
209
282
|
}
|
|
283
|
+
this.submittingDir = undefined;
|
|
210
284
|
this.leaveDetail(false);
|
|
211
285
|
this.rescan();
|
|
286
|
+
this.reconcileRoots();
|
|
212
287
|
this.repaint();
|
|
213
288
|
}
|
|
289
|
+
/** Give the raw TTY to a child process (native review editor). */
|
|
290
|
+
suspendForChild() {
|
|
291
|
+
this.suspended = true;
|
|
292
|
+
if (this.stdinListener !== undefined)
|
|
293
|
+
process.stdin.removeListener('data', this.stdinListener);
|
|
294
|
+
restoreTerminal();
|
|
295
|
+
}
|
|
296
|
+
/** Retake the TTY after the child exits and force a full repaint. */
|
|
297
|
+
resumeAfterChild() {
|
|
298
|
+
this.suspended = false;
|
|
299
|
+
if (this.closed)
|
|
300
|
+
return;
|
|
301
|
+
setupTerminal();
|
|
302
|
+
if (this.stdinListener !== undefined)
|
|
303
|
+
process.stdin.on('data', this.stdinListener);
|
|
304
|
+
this.frame = [];
|
|
305
|
+
}
|
|
306
|
+
resolvedRoots() {
|
|
307
|
+
return this.options.roots ?? listInboxRoots().filter((root) => root.available).map((root) => root.root);
|
|
308
|
+
}
|
|
309
|
+
/** Owner-boundary reconciliation for resolved results still lacking an ack.
|
|
310
|
+
* Guarded so overlapping fs events cannot stack concurrent scans. */
|
|
311
|
+
reconcileRoots() {
|
|
312
|
+
if (this.reconciling)
|
|
313
|
+
return;
|
|
314
|
+
this.reconciling = true;
|
|
315
|
+
const roots = this.resolvedRoots();
|
|
316
|
+
void (async () => {
|
|
317
|
+
try {
|
|
318
|
+
for (const root of roots) {
|
|
319
|
+
try {
|
|
320
|
+
await reconcileCompletions(root);
|
|
321
|
+
}
|
|
322
|
+
catch { /* undelivered stays for the next pass */ }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
this.reconciling = false;
|
|
327
|
+
}
|
|
328
|
+
})();
|
|
329
|
+
}
|
|
214
330
|
leaveDetail(release = true) {
|
|
215
331
|
this.adapter?.close();
|
|
216
332
|
this.adapter = undefined;
|
|
@@ -237,7 +353,31 @@ export class InboxController {
|
|
|
237
353
|
const selected = this.items[this.selectedIndex];
|
|
238
354
|
if (selected === undefined)
|
|
239
355
|
return [` ${DIM}Select a pending interaction.${RESET}`];
|
|
240
|
-
const lines = [` ${BOLD}${CYAN}${selected.title}${RESET}`, '', ` ${DIM}${selected.kind} · ${selected.source
|
|
356
|
+
const lines = [` ${BOLD}${CYAN}${selected.title}${RESET}`, '', ` ${DIM}${selected.kind} · ${sourceLabel(selected.source)}${RESET}`];
|
|
357
|
+
if (selected.kind === 'review') {
|
|
358
|
+
lines.push('', ` ${DIM}${selected.file}${RESET}`);
|
|
359
|
+
const draft = readJson(progressPath(selected.dir))?.comments;
|
|
360
|
+
const draftCount = Array.isArray(draft) ? draft.length : 0;
|
|
361
|
+
lines.push(` ${DIM}${draftCount} draft comment${draftCount === 1 ? '' : 's'}${RESET}`, '');
|
|
362
|
+
let md = '';
|
|
363
|
+
try {
|
|
364
|
+
md = readFileSync(selected.file, 'utf8');
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
md = '';
|
|
368
|
+
}
|
|
369
|
+
if (md === '')
|
|
370
|
+
lines.push(` ${DIM}(source file unavailable)${RESET}`);
|
|
371
|
+
else
|
|
372
|
+
for (const rendered of renderMarkdown(md, Math.max(1, width - 2)))
|
|
373
|
+
lines.push(` ${rendered}`);
|
|
374
|
+
lines.push('', ` ${DIM}Enter${RESET} start review ${DIM}j/k${RESET} select ${DIM}q${RESET} close`);
|
|
375
|
+
while (lines.length < rows)
|
|
376
|
+
lines.push('');
|
|
377
|
+
// Rendered markdown carries ANSI; slicing by column count would sever
|
|
378
|
+
// escape sequences, so review preview lines are returned unsliced.
|
|
379
|
+
return lines;
|
|
380
|
+
}
|
|
241
381
|
if (selected.kind === 'deck') {
|
|
242
382
|
const deck = readJson(deckPath(selected.dir));
|
|
243
383
|
if (deck !== null) {
|
|
@@ -267,7 +407,9 @@ export class InboxController {
|
|
|
267
407
|
return next;
|
|
268
408
|
}
|
|
269
409
|
repaint(clear = false) {
|
|
270
|
-
|
|
410
|
+
// While a child owns the TTY (native review), fs-watch invalidations must
|
|
411
|
+
// not scribble the inbox frame over the editor's screen.
|
|
412
|
+
if (this.closed || !this.running || this.suspended)
|
|
271
413
|
return;
|
|
272
414
|
if (clear)
|
|
273
415
|
process.stdout.write('\x1b[2J\x1b[H');
|
|
@@ -279,10 +421,9 @@ export class InboxController {
|
|
|
279
421
|
this.frame = diff.nextPrevFrame;
|
|
280
422
|
}
|
|
281
423
|
watchRoots() {
|
|
282
|
-
const
|
|
283
|
-
for (const root of roots) {
|
|
424
|
+
for (const root of this.resolvedRoots()) {
|
|
284
425
|
try {
|
|
285
|
-
this.watchers.push(watch(root, () => this.invalidate()));
|
|
426
|
+
this.watchers.push(watch(root, () => { this.invalidate(); this.reconcileRoots(); }));
|
|
286
427
|
}
|
|
287
428
|
catch { /* unavailable roots remain discoverable through later rescans */ }
|
|
288
429
|
}
|
|
@@ -309,3 +450,8 @@ export class InboxController {
|
|
|
309
450
|
}
|
|
310
451
|
}
|
|
311
452
|
function visibleWidth(line) { return line.replace(/\x1b\[[0-9;]*m/g, '').length; }
|
|
453
|
+
/** M-i (Option/Alt+I) reaches the controller as the two-byte ESC-i sequence. */
|
|
454
|
+
function isToggleCloseChord(input) { return input === '\x1bi' || input === '\x1bI'; }
|
|
455
|
+
/** Prefer a human-meaningful source label, falling back to the raw node id
|
|
456
|
+
* before an opaque "unknown source" — a crouter ticket always carries nodeId. */
|
|
457
|
+
function sourceLabel(source) { return source.sessionName ?? source.askedBy ?? source.nodeId ?? 'unknown source'; }
|
|
@@ -7,6 +7,8 @@ export interface ReviewAdapterOptions {
|
|
|
7
7
|
claim: TicketClaim;
|
|
8
8
|
editor?: ReviewOptions['editor'];
|
|
9
9
|
onSubmitted?: (result: TicketResult) => Promise<void> | void;
|
|
10
|
+
/** Human pressed Option/Alt+I inside the editor: close the whole inbox. */
|
|
11
|
+
onClose?: () => Promise<void> | void;
|
|
10
12
|
}
|
|
11
13
|
/** Controller-owned native review child. It persists drafts but delegates the sole final write to tickets.ts. */
|
|
12
14
|
export declare class ReviewAdapter {
|
package/dist/inbox/tui.js
CHANGED
|
@@ -28,7 +28,7 @@ export function buildInboxLines(items, width, selectedIndex) {
|
|
|
28
28
|
const item = items[index];
|
|
29
29
|
const kind = item.kind === 'deck' ? item.interactionKind ?? 'decision' : 'review';
|
|
30
30
|
const icon = KIND_ICON[kind] ?? '·';
|
|
31
|
-
const source = item.source.sessionName ?? item.source.askedBy ?? '';
|
|
31
|
+
const source = item.source.sessionName ?? item.source.askedBy ?? item.source.nodeId ?? '';
|
|
32
32
|
const age = formatTimeAgo(item.blockedSince);
|
|
33
33
|
const cursor = index === selectedIndex ? `${CYAN}▸${RESET} ` : ' ';
|
|
34
34
|
const titleWidth = Math.max(10, contentWidth - source.length - age.length - 8);
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* degradation path: `uv` absent → one stderr remediation line + plaintext
|
|
6
6
|
* fallback. win32 → plaintext (no renderer).
|
|
7
7
|
*
|
|
8
|
+
* Steady state is subprocess-free: a valid stamp is trusted outright. The
|
|
9
|
+
* spawn-based verification (`binaryOk` + `installedVersion`) runs only when the
|
|
10
|
+
* stamp is absent or stale — once, after which the venv is re-stamped — and the
|
|
11
|
+
* full `uv` reinstall runs only when the binary is actually missing or drifted.
|
|
12
|
+
*
|
|
8
13
|
* Invoked at postinstall AND lazily on the first render/check/display call,
|
|
9
14
|
* so `npm ci --ignore-scripts` consumers still self-heal on first use.
|
|
10
15
|
*/
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, statSync } from 'node:fs';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
5
5
|
import stringWidth from 'string-width';
|
|
@@ -27,7 +27,48 @@ const PKG_ROOT = findPkgRoot();
|
|
|
27
27
|
const VENV_DIR = resolve(PKG_ROOT, '.venv');
|
|
28
28
|
const VENV_BIN = resolve(PKG_ROOT, '.venv/bin/termrender');
|
|
29
29
|
const VENV_PYTHON = resolve(PKG_ROOT, '.venv/bin/python');
|
|
30
|
+
// Stamp written after a verified install: records the pin we provisioned and
|
|
31
|
+
// the binary's mtime at that moment. Steady-state validation is a stat + a
|
|
32
|
+
// tiny JSON read against this — no subprocess — so attach no longer pays the
|
|
33
|
+
// ~149ms `termrender -h` + `importlib.metadata` spawn tax on every launch.
|
|
34
|
+
const VENV_STAMP = resolve(PKG_ROOT, '.venv/.hl-termrender-stamp.json');
|
|
30
35
|
let rendererState = 'unchecked';
|
|
36
|
+
function readStamp() {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(readFileSync(VENV_STAMP, 'utf8'));
|
|
39
|
+
if (typeof parsed.version === 'string' && typeof parsed.mtimeMs === 'number')
|
|
40
|
+
return parsed;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function writeStamp() {
|
|
48
|
+
try {
|
|
49
|
+
writeFileSync(VENV_STAMP, JSON.stringify({ version: TERMRENDER_VERSION, mtimeMs: statSync(VENV_BIN).mtimeMs }));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Best-effort: an unwritable stamp just means the next process re-verifies
|
|
53
|
+
// the slow way once and re-stamps. Correctness is unaffected.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Cheap steady-state trust: the pinned binary is present and unchanged since
|
|
57
|
+
// the verified install that wrote the stamp. A version bump or an out-of-band
|
|
58
|
+
// rewrite of the binary changes the pin or the mtime and forces re-provision.
|
|
59
|
+
function stampValid() {
|
|
60
|
+
if (!existsSync(VENV_BIN))
|
|
61
|
+
return false;
|
|
62
|
+
const stamp = readStamp();
|
|
63
|
+
if (!stamp || stamp.version !== TERMRENDER_VERSION)
|
|
64
|
+
return false;
|
|
65
|
+
try {
|
|
66
|
+
return statSync(VENV_BIN).mtimeMs === stamp.mtimeMs;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
31
72
|
function binaryOk() {
|
|
32
73
|
if (!existsSync(VENV_BIN))
|
|
33
74
|
return false;
|
|
@@ -76,6 +117,11 @@ function uvAvailable() {
|
|
|
76
117
|
* degradation path: `uv` absent → one stderr remediation line + plaintext
|
|
77
118
|
* fallback. win32 → plaintext (no renderer).
|
|
78
119
|
*
|
|
120
|
+
* Steady state is subprocess-free: a valid stamp is trusted outright. The
|
|
121
|
+
* spawn-based verification (`binaryOk` + `installedVersion`) runs only when the
|
|
122
|
+
* stamp is absent or stale — once, after which the venv is re-stamped — and the
|
|
123
|
+
* full `uv` reinstall runs only when the binary is actually missing or drifted.
|
|
124
|
+
*
|
|
79
125
|
* Invoked at postinstall AND lazily on the first render/check/display call,
|
|
80
126
|
* so `npm ci --ignore-scripts` consumers still self-heal on first use.
|
|
81
127
|
*/
|
|
@@ -86,7 +132,16 @@ export function ensureRenderer() {
|
|
|
86
132
|
rendererState = 'unavailable';
|
|
87
133
|
return;
|
|
88
134
|
}
|
|
135
|
+
// Steady state: trust the stamp — zero subprocess spawns.
|
|
136
|
+
if (stampValid()) {
|
|
137
|
+
rendererState = 'ready';
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// No/stale stamp but the correct binary is already present (a venv from a
|
|
141
|
+
// pre-stamp humanloop, or an interrupted stamp write): verify once the slow
|
|
142
|
+
// way, stamp it, and skip the reinstall.
|
|
89
143
|
if (binaryOk() && installedVersion() === TERMRENDER_VERSION) {
|
|
144
|
+
writeStamp();
|
|
90
145
|
rendererState = 'ready';
|
|
91
146
|
return;
|
|
92
147
|
}
|
|
@@ -113,8 +168,12 @@ export function ensureRenderer() {
|
|
|
113
168
|
rendererState = 'unavailable';
|
|
114
169
|
return;
|
|
115
170
|
}
|
|
116
|
-
|
|
117
|
-
|
|
171
|
+
if (binaryOk() && installedVersion() === TERMRENDER_VERSION) {
|
|
172
|
+
writeStamp();
|
|
173
|
+
rendererState = 'ready';
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
rendererState = 'unavailable';
|
|
118
177
|
process.stderr.write('[hl] termrender install completed but health check failed; using plaintext fallback\n');
|
|
119
178
|
}
|
|
120
179
|
}
|
|
@@ -124,7 +183,7 @@ export function isRendererReady() {
|
|
|
124
183
|
return true;
|
|
125
184
|
if (rendererState === 'unavailable')
|
|
126
185
|
return false;
|
|
127
|
-
return process.platform !== 'win32' && binaryOk();
|
|
186
|
+
return process.platform !== 'win32' && (stampValid() || binaryOk());
|
|
128
187
|
}
|
|
129
188
|
// ── Plaintext fallback helpers (kept here so this is the only termrender site) ─
|
|
130
189
|
const CONTROL_CHARS_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b[@-_]|[\x00-\x08\x0B\x0E-\x1F\x7F-\x9F]/g;
|
package/dist/tui/tmux.js
CHANGED
|
@@ -22,7 +22,10 @@ function tmux(socket, args) {
|
|
|
22
22
|
}
|
|
23
23
|
function quote(value) { return `'${value.replace(/'/g, `'\\''`)}'`; }
|
|
24
24
|
function bindingCommand() {
|
|
25
|
-
|
|
25
|
+
// --quiet keeps the background `run-shell -b` binding from printing its result JSON, which
|
|
26
|
+
// tmux would otherwise surface as a view-mode overlay on the active pane. The popup opening
|
|
27
|
+
// is the feedback; a genuine failure still prints (see the CLI toggle action).
|
|
28
|
+
return 'hl inbox toggle --quiet --tmux-socket "#{socket_path}" --tmux-client "#{client_name}" --target-pane "#{pane_id}"';
|
|
26
29
|
}
|
|
27
30
|
function configuredKeyPath() {
|
|
28
31
|
const state = process.env['XDG_STATE_HOME'] || join(homedir(), '.local', 'state');
|
|
@@ -121,8 +124,11 @@ export async function toggleInboxPopup(target) {
|
|
|
121
124
|
mkdirSync(join(paths.controlSocket, '..'), { recursive: true, mode: 0o700 });
|
|
122
125
|
if (await requestPopupClose(paths.controlSocket))
|
|
123
126
|
return 'closed';
|
|
127
|
+
// A concurrent toggle for this same client won the startup lock and is opening the popup.
|
|
128
|
+
// This gesture must not launch a second one; report a benign no-op close rather than a
|
|
129
|
+
// generic failure, so exactly one popup exists and the loser never exits with an error.
|
|
124
130
|
if (!acquireStartupLock(paths.startupLock))
|
|
125
|
-
return '
|
|
131
|
+
return 'closed';
|
|
126
132
|
try {
|
|
127
133
|
// Re-probe under the lock: between the first probe and acquiring the lock a concurrent
|
|
128
134
|
// toggle may have brought a live popup up. Deleting its control socket now would orphan
|
|
@@ -156,7 +162,12 @@ async function launchPopup(socket, target, controlSocket, command) {
|
|
|
156
162
|
let stderr = '';
|
|
157
163
|
child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); });
|
|
158
164
|
const exited = new Promise((resolve) => {
|
|
159
|
-
|
|
165
|
+
// `display-popup -E` blocks until our popup closes, so a successful open keeps the child
|
|
166
|
+
// alive while the control socket comes up (detected below). An early exit before that means
|
|
167
|
+
// the popup command never ran: tmux 3.x silently declines to stack a popup on a client that
|
|
168
|
+
// already shows one (exit 0, command dropped), which is exactly the foreign-popup case the
|
|
169
|
+
// design must report as `other_popup` without disturbing the existing popup or its process.
|
|
170
|
+
child.once('exit', (code) => resolve(code === 0 || /popup/i.test(stderr) ? 'other_popup' : 'failed'));
|
|
160
171
|
child.once('error', () => resolve('failed'));
|
|
161
172
|
});
|
|
162
173
|
const { Socket } = await import('node:net');
|
|
@@ -176,7 +187,7 @@ async function launchPopup(socket, target, controlSocket, command) {
|
|
|
176
187
|
return 'opened';
|
|
177
188
|
}
|
|
178
189
|
if (outcome !== 'pending')
|
|
179
|
-
return outcome
|
|
190
|
+
return outcome;
|
|
180
191
|
}
|
|
181
192
|
return 'failed';
|
|
182
193
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crouton-kit/humanloop",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.35",
|
|
4
4
|
"description": "Human-in-the-loop decision TUI — agents write questions, humans answer them",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"dev": "tsx src/cli.ts",
|
|
30
30
|
"link": "npm link",
|
|
31
31
|
"postinstall": "node dist/scripts/install-renderer.js || true",
|
|
32
|
-
"test": "tsx src/__tests__/inbox-core.test.ts && tsx src/__tests__/mount-panel.test.ts && tsx src/__tests__/feedback.test.ts && tsx src/__tests__/browser-server.test.ts && tsx web/src/__tests__/review-sourcemap.test.ts && tsx web/src/__tests__/review-reducer.test.ts && tsx web/src/__tests__/review-keymap.test.ts && tsx web/src/__tests__/review-markdown-instrumentation.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/app-deck-regression.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/review-surface-conflict.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/app-ws-close.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/review-surface-takeback.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/review-surface-submit-race.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/review-surface-stale-submit-failure.test.ts && tsx src/__tests__/inbox-popup.test.ts"
|
|
32
|
+
"test": "tsx src/__tests__/inbox-core.test.ts && tsx src/__tests__/inbox-controller.test.ts && tsx src/__tests__/review-adapter.test.ts && tsx src/__tests__/mount-panel.test.ts && tsx src/__tests__/feedback.test.ts && tsx src/__tests__/browser-server.test.ts && tsx web/src/__tests__/review-sourcemap.test.ts && tsx web/src/__tests__/review-reducer.test.ts && tsx web/src/__tests__/review-keymap.test.ts && tsx web/src/__tests__/review-markdown-instrumentation.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/app-deck-regression.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/review-surface-conflict.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/app-ws-close.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/review-surface-takeback.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/review-surface-submit-race.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/review-surface-stale-submit-failure.test.ts && tsx src/__tests__/inbox-popup.test.ts"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@r-cli/sdk": "^1.3.0",
|