@crouton-kit/humanloop 0.3.34 → 0.3.36
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 +21 -8
- 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 +166 -13
- package/dist/inbox/review-adapter.d.ts +2 -0
- package/dist/inbox/review-adapter.js +1 -0
- package/dist/inbox/tui.js +7 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/render/termrender.d.ts +10 -6
- package/dist/render/termrender.js +326 -38
- package/dist/tui/tmux.d.ts +5 -7
- package/dist/tui/tmux.js +24 -70
- package/dist/types.d.ts +0 -5
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -8,7 +8,7 @@ import { managedInboxRoot, registerInboxRoot, registeredInboxRoot, unregisterInb
|
|
|
8
8
|
import { submitDeck, submitReview } from './inbox/tickets.js';
|
|
9
9
|
import { scanInbox } from './inbox/scan.js';
|
|
10
10
|
import { openInboxPopup } from './surfaces/inbox-popup.js';
|
|
11
|
-
import {
|
|
11
|
+
import { toggleInboxPopup } from './tui/tmux.js';
|
|
12
12
|
import { ask } from './api.js';
|
|
13
13
|
import { launchReview } from './editor/review.js';
|
|
14
14
|
import { writeFeedbackResult } from './editor/feedback.js';
|
|
@@ -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);
|
|
@@ -49,11 +50,26 @@ const inbox = program.command('inbox').description('Open, inspect, and configure
|
|
|
49
50
|
inbox.command('open').description('Open the inbox controller in this human TTY.').option('--root <path>', 'filter to a registered root', (value, prior) => [...prior, value], []).option('--control-socket <path>', 'popup control socket').action(async (options) => {
|
|
50
51
|
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
51
52
|
fail('hl inbox open requires an interactive TTY; use hl inbox list for read-only output');
|
|
52
|
-
|
|
53
|
+
try {
|
|
54
|
+
await openInboxPopup(options.controlSocket, roots(options.root));
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
fail(error);
|
|
58
|
+
}
|
|
59
|
+
// This process IS the popup: `tmux display-popup -E` keeps the popup on screen
|
|
60
|
+
// until it exits. Once the controller has closed, exit hard — a stray live
|
|
61
|
+
// handle (e.g. an in-flight completion-handler child that hangs) must never
|
|
62
|
+
// keep a dismissed inbox occupying the terminal. Delivery is receipt-based
|
|
63
|
+
// and crash-safe, so cutting an unfinished handler here is always safe.
|
|
64
|
+
process.exit(0);
|
|
53
65
|
});
|
|
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) => {
|
|
66
|
+
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
67
|
const result = await toggleInboxPopup({ socket: options.tmuxSocket, client: options.tmuxClient, targetPane: options.targetPane });
|
|
56
|
-
|
|
68
|
+
// Under the `run-shell -b` binding, any stdout becomes a tmux view-mode overlay on the active
|
|
69
|
+
// pane. The opened/closed popup is its own feedback, so stay silent on every non-failure result;
|
|
70
|
+
// a genuine `failed` still prints so a broken binding is not invisible.
|
|
71
|
+
if (!options.quiet || result === 'failed')
|
|
72
|
+
emit({ result, next: result === 'not_in_tmux' ? 'Run hl inbox open in a human terminal.' : undefined });
|
|
57
73
|
process.exit(result === 'failed' || result === 'ambiguous_client' ? 1 : 0);
|
|
58
74
|
});
|
|
59
75
|
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))));
|
|
@@ -65,9 +81,6 @@ root.command('register').description('Register a root owned by a host.').require
|
|
|
65
81
|
});
|
|
66
82
|
root.command('unregister').description('Remove a matching root registration without deleting tickets.').requiredOption('--root <path>').requiredOption('--owner <owner>').action((options) => emit({ removed: unregisterInboxRoot(resolve(options.root), options.owner) }));
|
|
67
83
|
root.command('list').description('List registered roots and availability.').action(() => emit(listInboxRoots()));
|
|
68
|
-
inbox.command('bind').description('Install a collision-safe tmux inbox toggle binding.').option('--key <tmux-key>').action((options) => emit(installInboxBinding({ key: options.key })));
|
|
69
|
-
inbox.command('unbind').description('Remove the configured inbox binding only when it is humanloop-owned.').action(() => emit(unbindInboxBinding()));
|
|
70
|
-
inbox.command('binding').description('Inspect the configured inbox binding.').action(() => emit(inspectInboxBinding()));
|
|
71
84
|
program.command('deck').command('ask').description('Submit a durable deck ticket; it never changes tmux. --inline blocks in this terminal instead.').option('--root <path>').option('--inline', 'present the deck in this terminal and block until it is answered').action(async (options) => {
|
|
72
85
|
try {
|
|
73
86
|
const body = objectInput();
|
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,15 +1,18 @@
|
|
|
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 {
|
|
4
|
+
import { renderMarkdown } from '../render/termrender.js';
|
|
5
|
+
import { BOLD, CYAN, DIM, GRAY, RESET, YELLOW, clipLine } from '../tui/ansi.js';
|
|
5
6
|
import { buildInboxLines } from './tui.js';
|
|
6
7
|
import { inboxLayout } from './layout.js';
|
|
7
8
|
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) {
|
|
@@ -86,15 +98,26 @@ export class InboxController {
|
|
|
86
98
|
if (geometry.mode === 'detail')
|
|
87
99
|
return this.withStatus(detail);
|
|
88
100
|
const lines = [];
|
|
101
|
+
// Dim box-drawing rule in the single-column gutter the layout reserves
|
|
102
|
+
// (detailWidth = cols - listWidth - 1) so the list and detail read as two
|
|
103
|
+
// distinct panels rather than one run-together block.
|
|
104
|
+
const divider = `${GRAY}│${RESET}`;
|
|
89
105
|
for (let i = 0; i < geometry.height; i++) {
|
|
90
|
-
|
|
106
|
+
// Hard-clip the list line to its column: an overflowing row would eat
|
|
107
|
+
// the pad and push the divider out of alignment for that row alone.
|
|
108
|
+
const left = clipLine(list[i] ?? '', geometry.listWidth);
|
|
91
109
|
const right = detail[i] ?? '';
|
|
92
|
-
|
|
110
|
+
const pad = ' '.repeat(Math.max(0, geometry.listWidth - visibleWidth(left)));
|
|
111
|
+
lines.push(`${left}${pad}${divider}${right}`);
|
|
93
112
|
}
|
|
94
113
|
return this.withStatus(lines);
|
|
95
114
|
}
|
|
96
115
|
handleKey(input, key) {
|
|
97
|
-
|
|
116
|
+
// Option/Alt+I (M-i) and Ctrl-C request graceful close from ANY controller
|
|
117
|
+
// mode. A root-table binding cannot fire while the popup grabs client input,
|
|
118
|
+
// so the close-from-open path must live here — checked before adapter
|
|
119
|
+
// forwarding so it works in active deck freetext as well as the list.
|
|
120
|
+
if ((key.ctrl && input === 'c') || isToggleCloseChord(input)) {
|
|
98
121
|
this.close();
|
|
99
122
|
return;
|
|
100
123
|
}
|
|
@@ -117,7 +140,13 @@ export class InboxController {
|
|
|
117
140
|
}
|
|
118
141
|
activate() {
|
|
119
142
|
const item = this.items[this.selectedIndex];
|
|
120
|
-
if (item === undefined
|
|
143
|
+
if (item === undefined)
|
|
144
|
+
return;
|
|
145
|
+
if (item.kind === 'review') {
|
|
146
|
+
void this.activateReview(item);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (item.kind !== 'deck')
|
|
121
150
|
return;
|
|
122
151
|
const claim = claimTicket(item.dir);
|
|
123
152
|
if (claim === null) {
|
|
@@ -148,11 +177,55 @@ export class InboxController {
|
|
|
148
177
|
});
|
|
149
178
|
this.watchSelected(item.dir);
|
|
150
179
|
}
|
|
180
|
+
/** Claim the review, hand the whole popup TTY to the native editor, then
|
|
181
|
+
* converge to the on-disk outcome when it exits. Draft/final ownership stays
|
|
182
|
+
* in ReviewAdapter; the controller only owns the terminal handoff. */
|
|
183
|
+
async activateReview(item) {
|
|
184
|
+
const claim = claimTicket(item.dir);
|
|
185
|
+
if (claim === null) {
|
|
186
|
+
this.status = 'ticket is being edited by another inbox';
|
|
187
|
+
this.repaint();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const descriptor = readJson(reviewPath(item.dir));
|
|
191
|
+
if (descriptor === null) {
|
|
192
|
+
releaseClaim(item.dir, claim.token);
|
|
193
|
+
this.invalidate();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
let closeRequested = false;
|
|
197
|
+
this.reviewAdapter = new ReviewAdapter({ dir: item.dir, descriptor, claim, onClose: () => { closeRequested = true; } });
|
|
198
|
+
this.suspendForChild();
|
|
199
|
+
try {
|
|
200
|
+
await this.reviewAdapter.start();
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
this.status = error instanceof Error ? error.message : String(error);
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
this.reviewAdapter = undefined;
|
|
207
|
+
// M-i inside native review is a graceful close of the WHOLE inbox: the
|
|
208
|
+
// adapter already saved the draft and released the claim, so just tear the
|
|
209
|
+
// controller down instead of returning to the list. The ticket stays
|
|
210
|
+
// pending because no submit flag was written.
|
|
211
|
+
if (closeRequested) {
|
|
212
|
+
this.suspended = false;
|
|
213
|
+
this.close();
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
this.resumeAfterChild();
|
|
217
|
+
this.rescan();
|
|
218
|
+
this.reconcileRoots();
|
|
219
|
+
this.repaint(true);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
151
223
|
reloadSelectedDeck() { this.adapter?.reload(); this.repaint(); }
|
|
152
224
|
close() {
|
|
153
225
|
if (this.closed)
|
|
154
226
|
return;
|
|
155
227
|
this.closed = true;
|
|
228
|
+
void this.reviewAdapter?.stop();
|
|
156
229
|
this.leaveDetail();
|
|
157
230
|
for (const watcher of this.watchers)
|
|
158
231
|
watcher.close();
|
|
@@ -165,6 +238,9 @@ export class InboxController {
|
|
|
165
238
|
setupTerminal();
|
|
166
239
|
this.running = true;
|
|
167
240
|
this.watchRoots();
|
|
241
|
+
// Close the crash window between an earlier result publication and its
|
|
242
|
+
// handler launch: dispatch every resolved-but-undelivered ticket now.
|
|
243
|
+
this.reconcileRoots();
|
|
168
244
|
this.repaint(true);
|
|
169
245
|
const heartbeat = setInterval(() => {
|
|
170
246
|
if (this.claim !== undefined)
|
|
@@ -177,6 +253,7 @@ export class InboxController {
|
|
|
177
253
|
if (this.closed)
|
|
178
254
|
finish();
|
|
179
255
|
};
|
|
256
|
+
this.stdinListener = onData;
|
|
180
257
|
const onResize = () => this.resize();
|
|
181
258
|
let done = false;
|
|
182
259
|
this.finishRun = () => {
|
|
@@ -184,6 +261,7 @@ export class InboxController {
|
|
|
184
261
|
return;
|
|
185
262
|
done = true;
|
|
186
263
|
process.stdin.removeListener('data', onData);
|
|
264
|
+
this.stdinListener = undefined;
|
|
187
265
|
process.stdout.removeListener('resize', onResize);
|
|
188
266
|
clearInterval(heartbeat);
|
|
189
267
|
this.running = false;
|
|
@@ -200,17 +278,62 @@ export class InboxController {
|
|
|
200
278
|
const claim = this.claim;
|
|
201
279
|
if (claim === undefined)
|
|
202
280
|
return;
|
|
281
|
+
this.submittingDir = claim.dir;
|
|
203
282
|
try {
|
|
204
283
|
await this.finishDeck(claim.dir, responses, claim.token);
|
|
205
284
|
}
|
|
206
285
|
catch (error) {
|
|
286
|
+
this.submittingDir = undefined;
|
|
207
287
|
this.status = error instanceof Error ? error.message : String(error);
|
|
208
288
|
return;
|
|
209
289
|
}
|
|
290
|
+
this.submittingDir = undefined;
|
|
210
291
|
this.leaveDetail(false);
|
|
211
292
|
this.rescan();
|
|
293
|
+
this.reconcileRoots();
|
|
212
294
|
this.repaint();
|
|
213
295
|
}
|
|
296
|
+
/** Give the raw TTY to a child process (native review editor). */
|
|
297
|
+
suspendForChild() {
|
|
298
|
+
this.suspended = true;
|
|
299
|
+
if (this.stdinListener !== undefined)
|
|
300
|
+
process.stdin.removeListener('data', this.stdinListener);
|
|
301
|
+
restoreTerminal();
|
|
302
|
+
}
|
|
303
|
+
/** Retake the TTY after the child exits and force a full repaint. */
|
|
304
|
+
resumeAfterChild() {
|
|
305
|
+
this.suspended = false;
|
|
306
|
+
if (this.closed)
|
|
307
|
+
return;
|
|
308
|
+
setupTerminal();
|
|
309
|
+
if (this.stdinListener !== undefined)
|
|
310
|
+
process.stdin.on('data', this.stdinListener);
|
|
311
|
+
this.frame = [];
|
|
312
|
+
}
|
|
313
|
+
resolvedRoots() {
|
|
314
|
+
return this.options.roots ?? listInboxRoots().filter((root) => root.available).map((root) => root.root);
|
|
315
|
+
}
|
|
316
|
+
/** Owner-boundary reconciliation for resolved results still lacking an ack.
|
|
317
|
+
* Guarded so overlapping fs events cannot stack concurrent scans. */
|
|
318
|
+
reconcileRoots() {
|
|
319
|
+
if (this.reconciling)
|
|
320
|
+
return;
|
|
321
|
+
this.reconciling = true;
|
|
322
|
+
const roots = this.resolvedRoots();
|
|
323
|
+
void (async () => {
|
|
324
|
+
try {
|
|
325
|
+
for (const root of roots) {
|
|
326
|
+
try {
|
|
327
|
+
await reconcileCompletions(root);
|
|
328
|
+
}
|
|
329
|
+
catch { /* undelivered stays for the next pass */ }
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
finally {
|
|
333
|
+
this.reconciling = false;
|
|
334
|
+
}
|
|
335
|
+
})();
|
|
336
|
+
}
|
|
214
337
|
leaveDetail(release = true) {
|
|
215
338
|
this.adapter?.close();
|
|
216
339
|
this.adapter = undefined;
|
|
@@ -237,7 +360,31 @@ export class InboxController {
|
|
|
237
360
|
const selected = this.items[this.selectedIndex];
|
|
238
361
|
if (selected === undefined)
|
|
239
362
|
return [` ${DIM}Select a pending interaction.${RESET}`];
|
|
240
|
-
const lines = [` ${BOLD}${CYAN}${selected.title}${RESET}`, '', ` ${DIM}${selected.kind} · ${selected.source
|
|
363
|
+
const lines = [` ${BOLD}${CYAN}${selected.title}${RESET}`, '', ` ${DIM}${selected.kind} · ${sourceLabel(selected.source)}${RESET}`];
|
|
364
|
+
if (selected.kind === 'review') {
|
|
365
|
+
lines.push('', ` ${DIM}${selected.file}${RESET}`);
|
|
366
|
+
const draft = readJson(progressPath(selected.dir))?.comments;
|
|
367
|
+
const draftCount = Array.isArray(draft) ? draft.length : 0;
|
|
368
|
+
lines.push(` ${DIM}${draftCount} draft comment${draftCount === 1 ? '' : 's'}${RESET}`, '');
|
|
369
|
+
let md = '';
|
|
370
|
+
try {
|
|
371
|
+
md = readFileSync(selected.file, 'utf8');
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
md = '';
|
|
375
|
+
}
|
|
376
|
+
if (md === '')
|
|
377
|
+
lines.push(` ${DIM}(source file unavailable)${RESET}`);
|
|
378
|
+
else
|
|
379
|
+
for (const rendered of renderMarkdown(md, Math.max(1, width - 2)))
|
|
380
|
+
lines.push(` ${rendered}`);
|
|
381
|
+
lines.push('', ` ${DIM}Enter${RESET} start review ${DIM}j/k${RESET} select ${DIM}q${RESET} close`);
|
|
382
|
+
while (lines.length < rows)
|
|
383
|
+
lines.push('');
|
|
384
|
+
// Rendered markdown carries ANSI; slicing by column count would sever
|
|
385
|
+
// escape sequences, so review preview lines are returned unsliced.
|
|
386
|
+
return lines;
|
|
387
|
+
}
|
|
241
388
|
if (selected.kind === 'deck') {
|
|
242
389
|
const deck = readJson(deckPath(selected.dir));
|
|
243
390
|
if (deck !== null) {
|
|
@@ -267,7 +414,9 @@ export class InboxController {
|
|
|
267
414
|
return next;
|
|
268
415
|
}
|
|
269
416
|
repaint(clear = false) {
|
|
270
|
-
|
|
417
|
+
// While a child owns the TTY (native review), fs-watch invalidations must
|
|
418
|
+
// not scribble the inbox frame over the editor's screen.
|
|
419
|
+
if (this.closed || !this.running || this.suspended)
|
|
271
420
|
return;
|
|
272
421
|
if (clear)
|
|
273
422
|
process.stdout.write('\x1b[2J\x1b[H');
|
|
@@ -279,10 +428,9 @@ export class InboxController {
|
|
|
279
428
|
this.frame = diff.nextPrevFrame;
|
|
280
429
|
}
|
|
281
430
|
watchRoots() {
|
|
282
|
-
const
|
|
283
|
-
for (const root of roots) {
|
|
431
|
+
for (const root of this.resolvedRoots()) {
|
|
284
432
|
try {
|
|
285
|
-
this.watchers.push(watch(root, () => this.invalidate()));
|
|
433
|
+
this.watchers.push(watch(root, () => { this.invalidate(); this.reconcileRoots(); }));
|
|
286
434
|
}
|
|
287
435
|
catch { /* unavailable roots remain discoverable through later rescans */ }
|
|
288
436
|
}
|
|
@@ -309,3 +457,8 @@ export class InboxController {
|
|
|
309
457
|
}
|
|
310
458
|
}
|
|
311
459
|
function visibleWidth(line) { return line.replace(/\x1b\[[0-9;]*m/g, '').length; }
|
|
460
|
+
/** M-i (Option/Alt+I) reaches the controller as the two-byte ESC-i sequence. */
|
|
461
|
+
function isToggleCloseChord(input) { return input === '\x1bi' || input === '\x1bI'; }
|
|
462
|
+
/** Prefer a human-meaningful source label, falling back to the raw node id
|
|
463
|
+
* before an opaque "unknown source" — a crouter ticket always carries nodeId. */
|
|
464
|
+
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,8 +28,13 @@ 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
|
-
|
|
31
|
+
// Source and title share the row budget: an unbounded source (a long node
|
|
32
|
+
// or session name) would floor the title at its minimum and overflow the
|
|
33
|
+
// column, bending the panel divider. Shrink the source only when the row
|
|
34
|
+
// cannot hold it alongside the title's 10-col minimum.
|
|
35
|
+
const rawSource = item.source.sessionName ?? item.source.askedBy ?? item.source.nodeId ?? '';
|
|
32
36
|
const age = formatTimeAgo(item.blockedSince);
|
|
37
|
+
const source = truncateRow(rawSource, Math.max(8, contentWidth - age.length - 8 - 10));
|
|
33
38
|
const cursor = index === selectedIndex ? `${CYAN}▸${RESET} ` : ' ';
|
|
34
39
|
const titleWidth = Math.max(10, contentWidth - source.length - age.length - 8);
|
|
35
40
|
let row = `${cursor}${ansiColor(icon, KIND_COLOR[kind] ?? 'cyan')} `;
|
|
@@ -38,7 +43,7 @@ export function buildInboxLines(items, width, selectedIndex) {
|
|
|
38
43
|
row += `${BOLD}${truncateRow(item.title || `(${item.id.slice(0, 8)})`, titleWidth)}${RESET} ${DIM}${age}${RESET}`;
|
|
39
44
|
lines.push(row);
|
|
40
45
|
if (item.claim)
|
|
41
|
-
lines.push(` ${DIM}claimed by ${item.claim.owner}${RESET}`);
|
|
46
|
+
lines.push(` ${DIM}${truncateRow(`claimed by ${item.claim.owner}`, contentWidth - 6)}${RESET}`);
|
|
42
47
|
else if (item.subtitle)
|
|
43
48
|
lines.push(` ${DIM}${truncateRow(item.subtitle, contentWidth - 6)}${RESET}`);
|
|
44
49
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export { launchReview as review } from './editor/review.js';
|
|
|
5
5
|
export type { ReviewOptions } from './editor/review.js';
|
|
6
6
|
export { ask, notify, openInbox } from './api.js';
|
|
7
7
|
export { openInboxPopup } from './surfaces/inbox-popup.js';
|
|
8
|
-
export { toggleInboxPopup,
|
|
8
|
+
export { toggleInboxPopup, inboxToggleTmuxCommand, inboxPopupStyle } from './tui/tmux.js';
|
|
9
9
|
export { display } from './surfaces/display.js';
|
|
10
10
|
export { scanInbox } from './inbox/scan.js';
|
|
11
11
|
export { registerInboxRoot, unregisterInboxRoot, listInboxRoots, managedInboxRoot } from './inbox/registry.js';
|
|
@@ -21,6 +21,6 @@ export { notifyDeck } from './inbox/deck-factories.js';
|
|
|
21
21
|
export type { NotifyDeckOpts } from './inbox/deck-factories.js';
|
|
22
22
|
export { deckPath, reviewPath, responsePath, progressPath, visualsDir, interactionState, isResolved, isClaimed, atomicWriteJson, readJson, writeResponse, writeProgress, clearProgress, } from './inbox/convention.js';
|
|
23
23
|
export type { InteractionState } from './inbox/convention.js';
|
|
24
|
-
export type { Interaction, InteractionOption, InteractionResponse, InteractionKind, Deck, DeckSource, MountedPanel, MountedPanelOpts, GenerateVisual, VisualBlock, FeedbackComment, FeedbackResult, ResolutionEnvelope, InboxItem, DisplayOpts, ClaimSummary, TicketSummary, DeckTicketSummary, ReviewTicketSummary, ReviewDescriptor, DeckTicketResult, ReviewTicketResult, CanceledTicketResult, TicketResult,
|
|
24
|
+
export type { Interaction, InteractionOption, InteractionResponse, InteractionKind, Deck, DeckSource, MountedPanel, MountedPanelOpts, GenerateVisual, VisualBlock, FeedbackComment, FeedbackResult, ResolutionEnvelope, InboxItem, DisplayOpts, ClaimSummary, TicketSummary, DeckTicketSummary, ReviewTicketSummary, ReviewDescriptor, DeckTicketResult, ReviewTicketResult, CanceledTicketResult, TicketResult, CompletionEvent, } from './types.js';
|
|
25
25
|
export type { Key } from './tui/terminal.js';
|
|
26
26
|
export type { ConversationMessage } from './conversation/reader.js';
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ export { launchReview as review } from './editor/review.js';
|
|
|
5
5
|
// Interaction-layer surface (SDK).
|
|
6
6
|
export { ask, notify, openInbox } from './api.js';
|
|
7
7
|
export { openInboxPopup } from './surfaces/inbox-popup.js';
|
|
8
|
-
export { toggleInboxPopup,
|
|
8
|
+
export { toggleInboxPopup, inboxToggleTmuxCommand, inboxPopupStyle } from './tui/tmux.js';
|
|
9
9
|
export { display } from './surfaces/display.js';
|
|
10
10
|
export { scanInbox } from './inbox/scan.js';
|
|
11
11
|
export { registerInboxRoot, unregisterInboxRoot, listInboxRoots, managedInboxRoot } from './inbox/registry.js';
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Memoized, self-healing.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Memoized, self-healing. Guarantees at most one authoritative renderer
|
|
3
|
+
* lifecycle per process: trust a valid stamp outright (zero subprocess spawns),
|
|
4
|
+
* else run ONE provision/verify/publish transition under the exclusive lock.
|
|
5
|
+
* There is no permanent legacy-verifier fallback — the spawn-based verify
|
|
6
|
+
* (`binaryOk` + `installedVersion`) is a step INSIDE that single transition,
|
|
7
|
+
* always followed by publishing the stamp, never a lasting alternate path.
|
|
8
|
+
*
|
|
9
|
+
* Single degradation path: `uv` absent → one stderr remediation line + plaintext.
|
|
10
|
+
* win32 → plaintext (no renderer).
|
|
7
11
|
*
|
|
8
12
|
* Invoked at postinstall AND lazily on the first render/check/display call,
|
|
9
13
|
* so `npm ci --ignore-scripts` consumers still self-heal on first use.
|
|
10
14
|
*/
|
|
11
15
|
export declare function ensureRenderer(): void;
|
|
12
|
-
/** Cheap predicate — true when the pinned managed binary is
|
|
16
|
+
/** Cheap predicate — true when the pinned managed binary is verified ready. Does not install or spawn. */
|
|
13
17
|
export declare function isRendererReady(): boolean;
|
|
14
18
|
/** Render markdown to terminal lines via the pinned binary; plaintext fallback. */
|
|
15
19
|
export declare function renderMarkdown(md: string, width: number): string[];
|
|
@@ -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, openSync, closeSync, unlinkSync, renameSync, accessSync, realpathSync, constants, } 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,246 @@ 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
|
+
// Readiness marker written by the single authoritative provisioning transition
|
|
31
|
+
// after a verified install. It fingerprints the ACTUAL verified environment —
|
|
32
|
+
// launcher + interpreter (mtime, size, mode) and the interpreter's realpath —
|
|
33
|
+
// so steady-state validation is a handful of cheap fs stats (no subprocess),
|
|
34
|
+
// yet a stripped exec bit, a swapped venv Python, or a rewritten launcher all
|
|
35
|
+
// invalidate it. Deeper corruption an fs stat can't see (e.g. mangled
|
|
36
|
+
// site-packages under an unchanged launcher) is caught by the other half of
|
|
37
|
+
// the contract: any `ready` invocation that fails to RUN invalidates this
|
|
38
|
+
// marker (see invalidateRenderer), so the next process repairs. Together these
|
|
39
|
+
// remove the ~149ms `termrender -h` + `importlib.metadata` spawn tax from the
|
|
40
|
+
// steady path without letting a stale marker trust a broken renderer forever.
|
|
41
|
+
const VENV_STAMP = resolve(PKG_ROOT, '.venv/.hl-termrender-stamp.json');
|
|
42
|
+
// Provisioning lock — lives OUTSIDE .venv (which `uv venv --clear` wipes) so it
|
|
43
|
+
// survives a reinstall. Serializes venv mutation + stamp publication across
|
|
44
|
+
// processes: a stamp can never certify a concurrently-changing venv.
|
|
45
|
+
const VENV_LOCK = resolve(PKG_ROOT, '.hl-termrender.lock');
|
|
46
|
+
// A lock older than this is from a crashed process and may be stolen. Set
|
|
47
|
+
// comfortably above the worst-case held path (uv probe 5s + venv 60s + install
|
|
48
|
+
// 120s + re-verify ~10s ≈ 195s) so a slow-but-alive holder is never judged
|
|
49
|
+
// stale while it still holds.
|
|
50
|
+
const LOCK_STALE_MS = 300_000;
|
|
51
|
+
// Absolute cap on how long a waiter spins before giving up to plaintext for
|
|
52
|
+
// this session (the next launch retries) — a safety valve so a wedged holder
|
|
53
|
+
// can never hang a process, WITHOUT ever stealing a lock we can't prove stale.
|
|
54
|
+
const LOCK_GIVE_UP_MS = LOCK_STALE_MS + 60_000;
|
|
30
55
|
let rendererState = 'unchecked';
|
|
56
|
+
function isFp(x) {
|
|
57
|
+
const e = x;
|
|
58
|
+
return !!e && typeof e.mtimeMs === 'number' && typeof e.size === 'number' && typeof e.mode === 'number';
|
|
59
|
+
}
|
|
60
|
+
function fingerprint(path) {
|
|
61
|
+
try {
|
|
62
|
+
const s = statSync(path);
|
|
63
|
+
return { mtimeMs: s.mtimeMs, size: s.size, mode: s.mode };
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function fpMatch(a, b) {
|
|
70
|
+
return !!a && a.mtimeMs === b.mtimeMs && a.size === b.size && a.mode === b.mode;
|
|
71
|
+
}
|
|
72
|
+
function readStamp() {
|
|
73
|
+
try {
|
|
74
|
+
const p = JSON.parse(readFileSync(VENV_STAMP, 'utf8'));
|
|
75
|
+
if (p && typeof p.version === 'string' && isFp(p.bin) && isFp(p.python) && typeof p.pythonRealpath === 'string') {
|
|
76
|
+
return p;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// Publish the readiness marker for the state we just verified. Failure to
|
|
85
|
+
// persist is surfaced explicitly (not swallowed): the renderer works for THIS
|
|
86
|
+
// process, but every future launch re-verifies the slow way until the marker
|
|
87
|
+
// can be written — the operator should know why launches stay slow.
|
|
88
|
+
function publishStamp() {
|
|
89
|
+
const bin = fingerprint(VENV_BIN);
|
|
90
|
+
const python = fingerprint(VENV_PYTHON);
|
|
91
|
+
if (!bin || !python) {
|
|
92
|
+
process.stderr.write('[hl] termrender stamp skipped: venv files vanished immediately after verify\n');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
let pythonRealpath;
|
|
96
|
+
try {
|
|
97
|
+
pythonRealpath = realpathSync(VENV_PYTHON);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
pythonRealpath = VENV_PYTHON;
|
|
101
|
+
}
|
|
102
|
+
const stamp = { version: TERMRENDER_VERSION, bin, python, pythonRealpath };
|
|
103
|
+
// Atomic publish: write a temp then rename, so a crash mid-write can't leave
|
|
104
|
+
// a half-written stamp and a concurrent reader never observes a torn file.
|
|
105
|
+
const tmp = `${VENV_STAMP}.${process.pid}.tmp`;
|
|
106
|
+
try {
|
|
107
|
+
writeFileSync(tmp, JSON.stringify(stamp));
|
|
108
|
+
renameSync(tmp, VENV_STAMP);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
try {
|
|
112
|
+
unlinkSync(tmp);
|
|
113
|
+
}
|
|
114
|
+
catch { /* nothing to clean up */ }
|
|
115
|
+
process.stderr.write(`[hl] termrender ready but stamp not persisted (${err instanceof Error ? err.message : String(err)}); ` +
|
|
116
|
+
'future launches will re-verify the slow way\n');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Invalidate the readiness marker when a supposedly-ready renderer misbehaves
|
|
120
|
+
// in a way that implicates the environment (spawn fault, or a `doc render`
|
|
121
|
+
// failure — render is best-effort by contract, so ANY failure means the tool,
|
|
122
|
+
// not the input, is broken; this catches site-packages corruption an fs stat
|
|
123
|
+
// can't see). Removes the on-disk marker so the next process repairs, and
|
|
124
|
+
// downgrades THIS process to plaintext to avoid retry thrash within the session.
|
|
125
|
+
function invalidateRenderer(reason) {
|
|
126
|
+
let removed = true;
|
|
127
|
+
try {
|
|
128
|
+
unlinkSync(VENV_STAMP);
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
// ENOENT means it's already gone (still invalidated); any other error means
|
|
132
|
+
// the marker SURVIVES and will keep certifying — say so honestly rather
|
|
133
|
+
// than promising a repair that can't happen until the file is removable.
|
|
134
|
+
if (err.code !== 'ENOENT')
|
|
135
|
+
removed = false;
|
|
136
|
+
}
|
|
137
|
+
rendererState = 'unavailable';
|
|
138
|
+
if (removed) {
|
|
139
|
+
process.stderr.write(`[hl] termrender invocation failed (${reason}); invalidated readiness, future launches will repair\n`);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
process.stderr.write(`[hl] termrender invocation failed (${reason}) but its readiness marker could not be removed; ` +
|
|
143
|
+
`future launches may keep trusting a broken renderer until ${VENV_STAMP} is deleted\n`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// True when a spawn was killed by its own timeout rather than failing to run —
|
|
147
|
+
// usually a slow/large document, not a broken environment. Excluded from
|
|
148
|
+
// invalidation so a reliably-slow render can't oscillate a healthy renderer
|
|
149
|
+
// (invalidate → slow re-verify → re-stamp) every session.
|
|
150
|
+
function isTimeout(err) {
|
|
151
|
+
const e = err;
|
|
152
|
+
return e?.code === 'ETIMEDOUT' || (!!e?.killed && !!e?.signal);
|
|
153
|
+
}
|
|
154
|
+
// Cheap steady-state trust — all fs stats, no subprocess. The pinned launcher
|
|
155
|
+
// is present, executable, and byte-for-byte the one the stamp verified; the
|
|
156
|
+
// interpreter it targets is the same file at the same realpath. Any of these
|
|
157
|
+
// drifting (version bump, stripped exec bit, rewritten launcher, swapped venv
|
|
158
|
+
// Python) forces the authoritative re-provision transition.
|
|
159
|
+
function stampValid() {
|
|
160
|
+
const stamp = readStamp();
|
|
161
|
+
if (!stamp || stamp.version !== TERMRENDER_VERSION)
|
|
162
|
+
return false;
|
|
163
|
+
try {
|
|
164
|
+
accessSync(VENV_BIN, constants.X_OK);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
if (!fpMatch(fingerprint(VENV_BIN), stamp.bin))
|
|
170
|
+
return false;
|
|
171
|
+
if (!fpMatch(fingerprint(VENV_PYTHON), stamp.python))
|
|
172
|
+
return false;
|
|
173
|
+
try {
|
|
174
|
+
if (realpathSync(VENV_PYTHON) !== stamp.pythonRealpath)
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
function sleepSync(ms) {
|
|
183
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
184
|
+
}
|
|
185
|
+
function lockIsStale() {
|
|
186
|
+
try {
|
|
187
|
+
const at = JSON.parse(readFileSync(VENV_LOCK, 'utf8')).at;
|
|
188
|
+
if (typeof at === 'number')
|
|
189
|
+
return Date.now() - at > LOCK_STALE_MS;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// Unreadable/malformed lock — fall through to mtime.
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
return Date.now() - statSync(VENV_LOCK).mtimeMs > LOCK_STALE_MS;
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return true; // vanished — the retry loop re-acquires
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Run `provision` while holding the exclusive provisioning lock. If another
|
|
202
|
+
// live process holds it, wait for that process to publish (re-checking the
|
|
203
|
+
// stamp) rather than mutating the venv concurrently. Stale locks are stolen.
|
|
204
|
+
// Steal a lock judged stale by renaming it to a unique name first: rename is
|
|
205
|
+
// atomic, so if two waiters race only the one whose rename succeeds owns (and
|
|
206
|
+
// deletes) it — the loser gets ENOENT and re-loops. This can never delete a
|
|
207
|
+
// lock a peer has freshly re-acquired (that peer holds a DIFFERENT inode at the
|
|
208
|
+
// same path; our rename of the old name either already happened or fails).
|
|
209
|
+
function stealStaleLock() {
|
|
210
|
+
const tmp = `${VENV_LOCK}.steal.${process.pid}.${Date.now()}`;
|
|
211
|
+
try {
|
|
212
|
+
renameSync(VENV_LOCK, tmp);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return; // lost the steal race or already gone — caller re-loops
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
unlinkSync(tmp);
|
|
219
|
+
}
|
|
220
|
+
catch { /* best effort */ }
|
|
221
|
+
}
|
|
222
|
+
function withProvisionLock(provision) {
|
|
223
|
+
const giveUpAt = Date.now() + LOCK_GIVE_UP_MS;
|
|
224
|
+
for (;;) {
|
|
225
|
+
let fd;
|
|
226
|
+
try {
|
|
227
|
+
fd = openSync(VENV_LOCK, 'wx'); // O_CREAT | O_EXCL — atomic acquire
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
if (err.code !== 'EEXIST')
|
|
231
|
+
throw err;
|
|
232
|
+
if (lockIsStale()) {
|
|
233
|
+
stealStaleLock();
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
// A live process is provisioning — give it a chance, then adopt its result.
|
|
237
|
+
// Never steal a lock we can't prove stale; if we wait too long, give up to
|
|
238
|
+
// plaintext this session rather than break mutual exclusion.
|
|
239
|
+
if (Date.now() > giveUpAt) {
|
|
240
|
+
rendererState = 'unavailable';
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
sleepSync(200);
|
|
244
|
+
if (stampValid()) {
|
|
245
|
+
rendererState = 'ready';
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
writeFileSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
252
|
+
}
|
|
253
|
+
catch { /* lock held regardless of whether the marker body wrote */ }
|
|
254
|
+
try {
|
|
255
|
+
provision();
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
try {
|
|
259
|
+
closeSync(fd);
|
|
260
|
+
}
|
|
261
|
+
catch { /* already closed */ }
|
|
262
|
+
try {
|
|
263
|
+
unlinkSync(VENV_LOCK);
|
|
264
|
+
}
|
|
265
|
+
catch { /* stolen as stale by another process */ }
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
31
270
|
function binaryOk() {
|
|
32
271
|
if (!existsSync(VENV_BIN))
|
|
33
272
|
return false;
|
|
@@ -70,11 +309,15 @@ function uvAvailable() {
|
|
|
70
309
|
}
|
|
71
310
|
}
|
|
72
311
|
/**
|
|
73
|
-
* Memoized, self-healing.
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
312
|
+
* Memoized, self-healing. Guarantees at most one authoritative renderer
|
|
313
|
+
* lifecycle per process: trust a valid stamp outright (zero subprocess spawns),
|
|
314
|
+
* else run ONE provision/verify/publish transition under the exclusive lock.
|
|
315
|
+
* There is no permanent legacy-verifier fallback — the spawn-based verify
|
|
316
|
+
* (`binaryOk` + `installedVersion`) is a step INSIDE that single transition,
|
|
317
|
+
* always followed by publishing the stamp, never a lasting alternate path.
|
|
318
|
+
*
|
|
319
|
+
* Single degradation path: `uv` absent → one stderr remediation line + plaintext.
|
|
320
|
+
* win32 → plaintext (no renderer).
|
|
78
321
|
*
|
|
79
322
|
* Invoked at postinstall AND lazily on the first render/check/display call,
|
|
80
323
|
* so `npm ci --ignore-scripts` consumers still self-heal on first use.
|
|
@@ -86,45 +329,75 @@ export function ensureRenderer() {
|
|
|
86
329
|
rendererState = 'unavailable';
|
|
87
330
|
return;
|
|
88
331
|
}
|
|
89
|
-
|
|
332
|
+
// Steady state: trust the stamp — zero subprocess spawns.
|
|
333
|
+
if (stampValid()) {
|
|
90
334
|
rendererState = 'ready';
|
|
91
335
|
return;
|
|
92
336
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
337
|
+
// No/invalid stamp → the single authoritative transition, serialized so no
|
|
338
|
+
// two processes mutate the venv (or certify it) concurrently.
|
|
339
|
+
provisionAndPublish();
|
|
340
|
+
}
|
|
341
|
+
// The one invalid-stamp transition. Under the exclusive lock: adopt a stamp a
|
|
342
|
+
// racing process just published; else verify the current venv (a healthy venv
|
|
343
|
+
// with no/stale stamp — pre-stamp humanloop, interrupted publish, or a peer
|
|
344
|
+
// that finished the venv but not the stamp — needs only re-verification, not a
|
|
345
|
+
// reinstall); reinstall via `uv` only when genuinely missing/drifted; then
|
|
346
|
+
// verify and publish. Every success path ends by publishing the stamp.
|
|
347
|
+
function provisionAndPublish() {
|
|
348
|
+
withProvisionLock(() => {
|
|
349
|
+
// A peer may have published between our unlocked stampValid() in
|
|
350
|
+
// ensureRenderer and our acquiring the lock here — adopt it, don't reinstall.
|
|
351
|
+
if (stampValid()) {
|
|
352
|
+
rendererState = 'ready';
|
|
353
|
+
return;
|
|
108
354
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
355
|
+
let verified = binaryOk() && installedVersion() === TERMRENDER_VERSION;
|
|
356
|
+
if (!verified) {
|
|
357
|
+
if (!uvAvailable()) {
|
|
358
|
+
process.stderr.write('[hl] termrender unavailable — install uv to enable rich rendering:\n' +
|
|
359
|
+
' curl -LsSf https://astral.sh/uv/install.sh | sh\n');
|
|
360
|
+
rendererState = 'unavailable';
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
// (Re)create the venv whenever the interpreter is missing — covers both
|
|
365
|
+
// "directory absent" and "directory present but bin/python stripped"
|
|
366
|
+
// (seen when pnpm rebuilds/dedupes node_modules or uv rotates its
|
|
367
|
+
// managed Python store). `--clear` wipes any partial state rather than
|
|
368
|
+
// refusing on the existing dir. Intact interpreter → reuse the venv.
|
|
369
|
+
if (!existsSync(VENV_PYTHON)) {
|
|
370
|
+
execFileSync('uv', ['venv', '--clear', VENV_DIR], { stdio: 'pipe', timeout: 60000 });
|
|
371
|
+
}
|
|
372
|
+
// `--reinstall` forces uv to rebuild the package and rewrite its entry
|
|
373
|
+
// point even when the pinned version already appears satisfied — so this
|
|
374
|
+
// path actually REPAIRS a corrupt-but-present install (the case that
|
|
375
|
+
// drove us here via invalidation), not just a clean version drift.
|
|
376
|
+
execFileSync('uv', ['pip', 'install', '--reinstall', '--python', VENV_PYTHON, `termrender==${TERMRENDER_VERSION}`], { stdio: 'pipe', timeout: 120000 });
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
process.stderr.write(`[hl] termrender install failed (${err instanceof Error ? err.message : String(err)}); using plaintext fallback\n`);
|
|
380
|
+
rendererState = 'unavailable';
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
verified = binaryOk() && installedVersion() === TERMRENDER_VERSION;
|
|
384
|
+
}
|
|
385
|
+
if (!verified) {
|
|
386
|
+
rendererState = 'unavailable';
|
|
387
|
+
process.stderr.write('[hl] termrender install completed but health check failed; using plaintext fallback\n');
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
publishStamp();
|
|
391
|
+
rendererState = 'ready';
|
|
392
|
+
});
|
|
120
393
|
}
|
|
121
|
-
/** Cheap predicate — true when the pinned managed binary is
|
|
394
|
+
/** Cheap predicate — true when the pinned managed binary is verified ready. Does not install or spawn. */
|
|
122
395
|
export function isRendererReady() {
|
|
123
396
|
if (rendererState === 'ready')
|
|
124
397
|
return true;
|
|
125
398
|
if (rendererState === 'unavailable')
|
|
126
399
|
return false;
|
|
127
|
-
return process.platform !== 'win32' &&
|
|
400
|
+
return process.platform !== 'win32' && stampValid();
|
|
128
401
|
}
|
|
129
402
|
// ── Plaintext fallback helpers (kept here so this is the only termrender site) ─
|
|
130
403
|
const CONTROL_CHARS_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b[@-_]|[\x00-\x08\x0B\x0E-\x1F\x7F-\x9F]/g;
|
|
@@ -208,8 +481,12 @@ export function renderMarkdown(md, width) {
|
|
|
208
481
|
_bodyCache.set(key, lines);
|
|
209
482
|
return lines;
|
|
210
483
|
}
|
|
211
|
-
catch {
|
|
212
|
-
|
|
484
|
+
catch (err) {
|
|
485
|
+
// `doc render` is best-effort, so a non-timeout failure implicates the
|
|
486
|
+
// tool, not the markdown: invalidate so the next process repairs. A
|
|
487
|
+
// timeout is a slow/large doc, not corruption — just fall to plaintext.
|
|
488
|
+
if (!isTimeout(err))
|
|
489
|
+
invalidateRenderer('doc render');
|
|
213
490
|
}
|
|
214
491
|
}
|
|
215
492
|
const fallback = wrap(sanitize(md), width);
|
|
@@ -229,6 +506,11 @@ export function checkMarkdown(md) {
|
|
|
229
506
|
timeout: 5000,
|
|
230
507
|
});
|
|
231
508
|
if (result.error) {
|
|
509
|
+
// A timeout (slow doc) is not an environment fault; anything else is a
|
|
510
|
+
// spawn fault → invalidate so the next process repairs.
|
|
511
|
+
const timedOut = result.error.code === 'ETIMEDOUT' || result.signal === 'SIGTERM';
|
|
512
|
+
if (!timedOut)
|
|
513
|
+
invalidateRenderer('doc check');
|
|
232
514
|
return { ok: false, error: `termrender: invocation failed: ${result.error.message}` };
|
|
233
515
|
}
|
|
234
516
|
let parsed = null;
|
|
@@ -270,7 +552,13 @@ export function displayInPane(path, opts = {}) {
|
|
|
270
552
|
encoding: 'utf-8',
|
|
271
553
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
272
554
|
});
|
|
273
|
-
|
|
555
|
+
// A spawn fault (binary broke) invalidates readiness; a non-zero exit (e.g.
|
|
556
|
+
// tmux refused, bad path) is not an environment fault and must not.
|
|
557
|
+
if (result.error) {
|
|
558
|
+
invalidateRenderer('pane open');
|
|
559
|
+
return {};
|
|
560
|
+
}
|
|
561
|
+
if (result.status !== 0)
|
|
274
562
|
return {};
|
|
275
563
|
// `encoding: 'utf-8'` makes spawnSync return stdout as a string.
|
|
276
564
|
const rawStdout = result.stdout;
|
package/dist/tui/tmux.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { InboxBindingState } from '../types.js';
|
|
2
1
|
export interface TmuxPopupTarget {
|
|
3
2
|
socket: string;
|
|
4
3
|
client: string;
|
|
@@ -9,12 +8,11 @@ export declare function popupPaths(target: TmuxPopupTarget): {
|
|
|
9
8
|
controlSocket: string;
|
|
10
9
|
startupLock: string;
|
|
11
10
|
};
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
export declare function unbindInboxBinding(socket?: string | undefined): InboxBindingState;
|
|
11
|
+
/** The argv crouter's tmux-binding installer binds on the root table to toggle the inbox popup.
|
|
12
|
+
* Humanloop owns the command text; crouter owns which key(s) run it (via its keybindings catalog
|
|
13
|
+
* and the single `installTmuxBindings()` manifest sweep). Humanloop no longer persists or
|
|
14
|
+
* reconciles any key itself. */
|
|
15
|
+
export declare function inboxToggleTmuxCommand(): string[];
|
|
18
16
|
export declare function tmuxSocketFromEnvironment(): string | undefined;
|
|
19
17
|
export declare function inferTmuxClient(socket: string, pane?: string | undefined): string | undefined | 'ambiguous';
|
|
20
18
|
export declare function toggleInboxPopup(target?: Partial<TmuxPopupTarget>): Promise<ToggleInboxPopupResult>;
|
package/dist/tui/tmux.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { execFileSync, spawn } from 'node:child_process';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { existsSync, mkdirSync,
|
|
5
|
-
import {
|
|
4
|
+
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
-
const DEFAULT_KEY = 'M-i';
|
|
8
7
|
const POPUP_TITLE = 'humanloop · inbox';
|
|
9
8
|
const POPUP_STYLE = 'bg=#20242d';
|
|
10
9
|
const POPUP_BORDER_STYLE = 'fg=#5c6370';
|
|
@@ -22,70 +21,17 @@ function tmux(socket, args) {
|
|
|
22
21
|
}
|
|
23
22
|
function quote(value) { return `'${value.replace(/'/g, `'\\''`)}'`; }
|
|
24
23
|
function bindingCommand() {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return DEFAULT_KEY;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
function writeConfiguredKey(key) {
|
|
40
|
-
mkdirSync(join(configuredKeyPath(), '..'), { recursive: true, mode: 0o700 });
|
|
41
|
-
writeFileSync(configuredKeyPath(), `${key}\n`, { mode: 0o600 });
|
|
42
|
-
}
|
|
43
|
-
function rootBinding(socket, key) {
|
|
44
|
-
try {
|
|
45
|
-
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
46
|
-
const match = tmux(socket, ['list-keys', '-T', 'root']).split('\n').map((row) => new RegExp(`^bind-key\\s+-T root\\s+${escaped}\\s+(.*)$`).exec(row)).find((entry) => entry !== null);
|
|
47
|
-
return match?.[1];
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
return undefined;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
function isCanonical(command) {
|
|
54
|
-
const normalized = command?.replace(/\\"/g, '"');
|
|
55
|
-
return normalized !== undefined && normalized.includes('hl inbox toggle') && normalized.includes('--tmux-socket "#{socket_path}"') && normalized.includes('--tmux-client "#{client_name}"') && normalized.includes('--target-pane "#{pane_id}"');
|
|
56
|
-
}
|
|
57
|
-
export function inspectInboxBinding(socket = tmuxSocketFromEnvironment()) {
|
|
58
|
-
const key = configuredKey();
|
|
59
|
-
if (socket === undefined)
|
|
60
|
-
return { state: 'unbound', key, isDefault: key === DEFAULT_KEY };
|
|
61
|
-
const command = rootBinding(socket, key);
|
|
62
|
-
return { state: command === undefined ? 'unbound' : isCanonical(command) ? 'installed' : 'collision', key, isDefault: key === DEFAULT_KEY };
|
|
63
|
-
}
|
|
64
|
-
export function installInboxBinding(opts = {}) {
|
|
65
|
-
const socket = opts.socket ?? tmuxSocketFromEnvironment();
|
|
66
|
-
const key = opts.key ?? configuredKey();
|
|
67
|
-
if (socket === undefined)
|
|
68
|
-
return { state: 'unbound', key, isDefault: key === DEFAULT_KEY };
|
|
69
|
-
const existing = rootBinding(socket, key);
|
|
70
|
-
if (existing !== undefined && !isCanonical(existing))
|
|
71
|
-
return { state: 'collision', key, isDefault: key === DEFAULT_KEY };
|
|
72
|
-
if (existing === undefined)
|
|
73
|
-
tmux(socket, ['bind-key', '-T', 'root', key, 'run-shell', '-b', bindingCommand()]);
|
|
74
|
-
if (opts.key !== undefined) {
|
|
75
|
-
// Switching to a new key: drop the previous configured key iff it still holds the
|
|
76
|
-
// canonical toggle, so bindings don't accrete and inspect/unbind track a single live key.
|
|
77
|
-
const previous = configuredKey();
|
|
78
|
-
if (previous !== key && isCanonical(rootBinding(socket, previous)))
|
|
79
|
-
tmux(socket, ['unbind-key', '-T', 'root', previous]);
|
|
80
|
-
writeConfiguredKey(key);
|
|
81
|
-
}
|
|
82
|
-
return { state: 'installed', key, isDefault: key === DEFAULT_KEY };
|
|
83
|
-
}
|
|
84
|
-
export function unbindInboxBinding(socket = tmuxSocketFromEnvironment()) {
|
|
85
|
-
const key = configuredKey();
|
|
86
|
-
if (socket !== undefined && isCanonical(rootBinding(socket, key)))
|
|
87
|
-
tmux(socket, ['unbind-key', '-T', 'root', key]);
|
|
88
|
-
return inspectInboxBinding(socket);
|
|
24
|
+
// --quiet keeps the background `run-shell -b` binding from printing its result JSON, which
|
|
25
|
+
// tmux would otherwise surface as a view-mode overlay on the active pane. The popup opening
|
|
26
|
+
// is the feedback; a genuine failure still prints (see the CLI toggle action).
|
|
27
|
+
return 'hl inbox toggle --quiet --tmux-socket "#{socket_path}" --tmux-client "#{client_name}" --target-pane "#{pane_id}"';
|
|
28
|
+
}
|
|
29
|
+
/** The argv crouter's tmux-binding installer binds on the root table to toggle the inbox popup.
|
|
30
|
+
* Humanloop owns the command text; crouter owns which key(s) run it (via its keybindings catalog
|
|
31
|
+
* and the single `installTmuxBindings()` manifest sweep). Humanloop no longer persists or
|
|
32
|
+
* reconciles any key itself. */
|
|
33
|
+
export function inboxToggleTmuxCommand() {
|
|
34
|
+
return ['run-shell', '-b', bindingCommand()];
|
|
89
35
|
}
|
|
90
36
|
export function tmuxSocketFromEnvironment() {
|
|
91
37
|
const value = process.env['TMUX'];
|
|
@@ -121,8 +67,11 @@ export async function toggleInboxPopup(target) {
|
|
|
121
67
|
mkdirSync(join(paths.controlSocket, '..'), { recursive: true, mode: 0o700 });
|
|
122
68
|
if (await requestPopupClose(paths.controlSocket))
|
|
123
69
|
return 'closed';
|
|
70
|
+
// A concurrent toggle for this same client won the startup lock and is opening the popup.
|
|
71
|
+
// This gesture must not launch a second one; report a benign no-op close rather than a
|
|
72
|
+
// generic failure, so exactly one popup exists and the loser never exits with an error.
|
|
124
73
|
if (!acquireStartupLock(paths.startupLock))
|
|
125
|
-
return '
|
|
74
|
+
return 'closed';
|
|
126
75
|
try {
|
|
127
76
|
// Re-probe under the lock: between the first probe and acquiring the lock a concurrent
|
|
128
77
|
// toggle may have brought a live popup up. Deleting its control socket now would orphan
|
|
@@ -156,7 +105,12 @@ async function launchPopup(socket, target, controlSocket, command) {
|
|
|
156
105
|
let stderr = '';
|
|
157
106
|
child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); });
|
|
158
107
|
const exited = new Promise((resolve) => {
|
|
159
|
-
|
|
108
|
+
// `display-popup -E` blocks until our popup closes, so a successful open keeps the child
|
|
109
|
+
// alive while the control socket comes up (detected below). An early exit before that means
|
|
110
|
+
// the popup command never ran: tmux 3.x silently declines to stack a popup on a client that
|
|
111
|
+
// already shows one (exit 0, command dropped), which is exactly the foreign-popup case the
|
|
112
|
+
// design must report as `other_popup` without disturbing the existing popup or its process.
|
|
113
|
+
child.once('exit', (code) => resolve(code === 0 || /popup/i.test(stderr) ? 'other_popup' : 'failed'));
|
|
160
114
|
child.once('error', () => resolve('failed'));
|
|
161
115
|
});
|
|
162
116
|
const { Socket } = await import('node:net');
|
|
@@ -176,7 +130,7 @@ async function launchPopup(socket, target, controlSocket, command) {
|
|
|
176
130
|
return 'opened';
|
|
177
131
|
}
|
|
178
132
|
if (outcome !== 'pending')
|
|
179
|
-
return outcome
|
|
133
|
+
return outcome;
|
|
180
134
|
}
|
|
181
135
|
return 'failed';
|
|
182
136
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -211,11 +211,6 @@ export interface CanceledTicketResult {
|
|
|
211
211
|
}
|
|
212
212
|
/** The sole canonical response.json union. */
|
|
213
213
|
export type TicketResult = DeckTicketResult | ReviewTicketResult | CanceledTicketResult;
|
|
214
|
-
export interface InboxBindingState {
|
|
215
|
-
state: 'installed' | 'collision' | 'unbound';
|
|
216
|
-
key: string;
|
|
217
|
-
isDefault: boolean;
|
|
218
|
-
}
|
|
219
214
|
export interface CompletionEvent {
|
|
220
215
|
schema: 'humanloop.completion/v1';
|
|
221
216
|
root: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crouton-kit/humanloop",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.36",
|
|
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",
|