@crouton-kit/humanloop 0.3.35 → 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 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 { installInboxBinding, inspectInboxBinding, toggleInboxPopup, unbindInboxBinding } from './tui/tmux.js';
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';
@@ -50,7 +50,18 @@ const inbox = program.command('inbox').description('Open, inspect, and configure
50
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) => {
51
51
  if (!process.stdin.isTTY || !process.stdout.isTTY)
52
52
  fail('hl inbox open requires an interactive TTY; use hl inbox list for read-only output');
53
- await openInboxPopup(options.controlSocket, roots(options.root));
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);
54
65
  });
55
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) => {
56
67
  const result = await toggleInboxPopup({ socket: options.tmuxSocket, client: options.tmuxClient, targetPane: options.targetPane });
@@ -70,9 +81,6 @@ root.command('register').description('Register a root owned by a host.').require
70
81
  });
71
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) }));
72
83
  root.command('list').description('List registered roots and availability.').action(() => emit(listInboxRoots()));
73
- inbox.command('bind').description('Install a collision-safe tmux inbox toggle binding.').option('--key <tmux-key>').action((options) => emit(installInboxBinding({ key: options.key })));
74
- inbox.command('unbind').description('Remove the configured inbox binding only when it is humanloop-owned.').action(() => emit(unbindInboxBinding()));
75
- inbox.command('binding').description('Inspect the configured inbox binding.').action(() => emit(inspectInboxBinding()));
76
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) => {
77
85
  try {
78
86
  const body = objectInput();
@@ -2,7 +2,7 @@ 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
4
  import { renderMarkdown } from '../render/termrender.js';
5
- import { BOLD, CYAN, DIM, RESET, YELLOW } from '../tui/ansi.js';
5
+ import { BOLD, CYAN, DIM, GRAY, RESET, YELLOW, clipLine } from '../tui/ansi.js';
6
6
  import { buildInboxLines } from './tui.js';
7
7
  import { inboxLayout } from './layout.js';
8
8
  import { scanInbox } from './scan.js';
@@ -98,10 +98,17 @@ export class InboxController {
98
98
  if (geometry.mode === 'detail')
99
99
  return this.withStatus(detail);
100
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}`;
101
105
  for (let i = 0; i < geometry.height; i++) {
102
- const left = list[i] ?? '';
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);
103
109
  const right = detail[i] ?? '';
104
- lines.push(`${left}${' '.repeat(Math.max(1, geometry.listWidth - visibleWidth(left)))} ${right}`);
110
+ const pad = ' '.repeat(Math.max(0, geometry.listWidth - visibleWidth(left)));
111
+ lines.push(`${left}${pad}${divider}${right}`);
105
112
  }
106
113
  return this.withStatus(lines);
107
114
  }
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
- const source = item.source.sessionName ?? item.source.askedBy ?? item.source.nodeId ?? '';
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, installInboxBinding, inspectInboxBinding, unbindInboxBinding, inboxPopupStyle } from './tui/tmux.js';
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, InboxBindingState, CompletionEvent, } from './types.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, 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, installInboxBinding, inspectInboxBinding, unbindInboxBinding, inboxPopupStyle } from './tui/tmux.js';
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,20 +1,19 @@
1
1
  /**
2
- * Memoized, self-healing. Ensures the pinned termrender binary exists inside
3
- * the humanloop-managed venv; (re)provisions it via `uv` when missing or the
4
- * version drifts from the pin. Runs at most once per process. Single
5
- * degradation path: `uv` absent one stderr remediation line + plaintext
6
- * fallback. win32 plaintext (no renderer).
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.
7
8
  *
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.
9
+ * Single degradation path: `uv` absent one stderr remediation line + plaintext.
10
+ * win32 plaintext (no renderer).
12
11
  *
13
12
  * Invoked at postinstall AND lazily on the first render/check/display call,
14
13
  * so `npm ci --ignore-scripts` consumers still self-heal on first use.
15
14
  */
16
15
  export declare function ensureRenderer(): void;
17
- /** Cheap predicate — true when the pinned managed binary is present and correct. Does not install. */
16
+ /** Cheap predicate — true when the pinned managed binary is verified ready. Does not install or spawn. */
18
17
  export declare function isRendererReady(): boolean;
19
18
  /** Render markdown to terminal lines via the pinned binary; plaintext fallback. */
20
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, readFileSync, writeFileSync, statSync } 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,47 +27,245 @@ 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.
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.
34
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;
35
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
+ }
36
72
  function readStamp() {
37
73
  try {
38
- const parsed = JSON.parse(readFileSync(VENV_STAMP, 'utf8'));
39
- if (typeof parsed.version === 'string' && typeof parsed.mtimeMs === 'number')
40
- return parsed;
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
+ }
41
78
  return null;
42
79
  }
43
80
  catch {
44
81
  return null;
45
82
  }
46
83
  }
47
- function writeStamp() {
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;
48
96
  try {
49
- writeFileSync(VENV_STAMP, JSON.stringify({ version: TERMRENDER_VERSION, mtimeMs: statSync(VENV_BIN).mtimeMs }));
97
+ pythonRealpath = realpathSync(VENV_PYTHON);
50
98
  }
51
99
  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.
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`);
54
144
  }
55
145
  }
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.
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.
59
159
  function stampValid() {
60
- if (!existsSync(VENV_BIN))
61
- return false;
62
160
  const stamp = readStamp();
63
161
  if (!stamp || stamp.version !== TERMRENDER_VERSION)
64
162
  return false;
65
163
  try {
66
- return statSync(VENV_BIN).mtimeMs === stamp.mtimeMs;
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;
67
176
  }
68
177
  catch {
69
178
  return false;
70
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
+ }
71
269
  }
72
270
  function binaryOk() {
73
271
  if (!existsSync(VENV_BIN))
@@ -111,16 +309,15 @@ function uvAvailable() {
111
309
  }
112
310
  }
113
311
  /**
114
- * Memoized, self-healing. Ensures the pinned termrender binary exists inside
115
- * the humanloop-managed venv; (re)provisions it via `uv` when missing or the
116
- * version drifts from the pin. Runs at most once per process. Single
117
- * degradation path: `uv` absent one stderr remediation line + plaintext
118
- * fallback. win32 plaintext (no renderer).
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.
119
318
  *
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.
319
+ * Single degradation path: `uv` absent one stderr remediation line + plaintext.
320
+ * win32 plaintext (no renderer).
124
321
  *
125
322
  * Invoked at postinstall AND lazily on the first render/check/display call,
126
323
  * so `npm ci --ignore-scripts` consumers still self-heal on first use.
@@ -137,53 +334,70 @@ export function ensureRenderer() {
137
334
  rendererState = 'ready';
138
335
  return;
139
336
  }
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.
143
- if (binaryOk() && installedVersion() === TERMRENDER_VERSION) {
144
- writeStamp();
145
- rendererState = 'ready';
146
- return;
147
- }
148
- if (!uvAvailable()) {
149
- process.stderr.write('[hl] termrender unavailable install uv to enable rich rendering:\n' +
150
- ' curl -LsSf https://astral.sh/uv/install.sh | sh\n');
151
- rendererState = 'unavailable';
152
- return;
153
- }
154
- try {
155
- // (Re)create the venv whenever the interpreter is missing — covers both
156
- // "directory absent" and "directory present but bin/python stripped"
157
- // (seen in the wild when pnpm rebuilds/dedupes node_modules or uv rotates
158
- // its managed Python store). `--clear` makes uv wipe any partial state
159
- // rather than refusing on the existing dir. If the interpreter is intact,
160
- // skip straight to `uv pip install` so version drift reuses the venv.
161
- if (!existsSync(VENV_PYTHON)) {
162
- execFileSync('uv', ['venv', '--clear', VENV_DIR], { stdio: 'pipe', timeout: 60000 });
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;
163
354
  }
164
- execFileSync('uv', ['pip', 'install', '--python', VENV_PYTHON, `termrender==${TERMRENDER_VERSION}`], { stdio: 'pipe', timeout: 120000 });
165
- }
166
- catch (err) {
167
- process.stderr.write(`[hl] termrender install failed (${err instanceof Error ? err.message : String(err)}); using plaintext fallback\n`);
168
- rendererState = 'unavailable';
169
- return;
170
- }
171
- if (binaryOk() && installedVersion() === TERMRENDER_VERSION) {
172
- writeStamp();
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();
173
391
  rendererState = 'ready';
174
- }
175
- else {
176
- rendererState = 'unavailable';
177
- process.stderr.write('[hl] termrender install completed but health check failed; using plaintext fallback\n');
178
- }
392
+ });
179
393
  }
180
- /** Cheap predicate — true when the pinned managed binary is present and correct. Does not install. */
394
+ /** Cheap predicate — true when the pinned managed binary is verified ready. Does not install or spawn. */
181
395
  export function isRendererReady() {
182
396
  if (rendererState === 'ready')
183
397
  return true;
184
398
  if (rendererState === 'unavailable')
185
399
  return false;
186
- return process.platform !== 'win32' && (stampValid() || binaryOk());
400
+ return process.platform !== 'win32' && stampValid();
187
401
  }
188
402
  // ── Plaintext fallback helpers (kept here so this is the only termrender site) ─
189
403
  const CONTROL_CHARS_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b[@-_]|[\x00-\x08\x0B\x0E-\x1F\x7F-\x9F]/g;
@@ -267,8 +481,12 @@ export function renderMarkdown(md, width) {
267
481
  _bodyCache.set(key, lines);
268
482
  return lines;
269
483
  }
270
- catch {
271
- /* fall through to plaintext */
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');
272
490
  }
273
491
  }
274
492
  const fallback = wrap(sanitize(md), width);
@@ -288,6 +506,11 @@ export function checkMarkdown(md) {
288
506
  timeout: 5000,
289
507
  });
290
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');
291
514
  return { ok: false, error: `termrender: invocation failed: ${result.error.message}` };
292
515
  }
293
516
  let parsed = null;
@@ -329,7 +552,13 @@ export function displayInPane(path, opts = {}) {
329
552
  encoding: 'utf-8',
330
553
  stdio: ['pipe', 'pipe', 'pipe'],
331
554
  });
332
- if (result.error || result.status !== 0)
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)
333
562
  return {};
334
563
  // `encoding: 'utf-8'` makes spawnSync return stdout as a string.
335
564
  const rawStdout = result.stdout;
@@ -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
- export declare function inspectInboxBinding(socket?: string | undefined): InboxBindingState;
13
- export declare function installInboxBinding(opts?: {
14
- socket?: string;
15
- key?: string;
16
- }): InboxBindingState;
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, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
- import { homedir, tmpdir } from 'node:os';
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';
@@ -27,68 +26,12 @@ function bindingCommand() {
27
26
  // is the feedback; a genuine failure still prints (see the CLI toggle action).
28
27
  return 'hl inbox toggle --quiet --tmux-socket "#{socket_path}" --tmux-client "#{client_name}" --target-pane "#{pane_id}"';
29
28
  }
30
- function configuredKeyPath() {
31
- const state = process.env['XDG_STATE_HOME'] || join(homedir(), '.local', 'state');
32
- return join(state, 'humanloop', 'inbox-key');
33
- }
34
- function configuredKey() {
35
- try {
36
- return readFileSync(configuredKeyPath(), 'utf8').trim() || DEFAULT_KEY;
37
- }
38
- catch {
39
- return DEFAULT_KEY;
40
- }
41
- }
42
- function writeConfiguredKey(key) {
43
- mkdirSync(join(configuredKeyPath(), '..'), { recursive: true, mode: 0o700 });
44
- writeFileSync(configuredKeyPath(), `${key}\n`, { mode: 0o600 });
45
- }
46
- function rootBinding(socket, key) {
47
- try {
48
- const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
49
- 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);
50
- return match?.[1];
51
- }
52
- catch {
53
- return undefined;
54
- }
55
- }
56
- function isCanonical(command) {
57
- const normalized = command?.replace(/\\"/g, '"');
58
- 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}"');
59
- }
60
- export function inspectInboxBinding(socket = tmuxSocketFromEnvironment()) {
61
- const key = configuredKey();
62
- if (socket === undefined)
63
- return { state: 'unbound', key, isDefault: key === DEFAULT_KEY };
64
- const command = rootBinding(socket, key);
65
- return { state: command === undefined ? 'unbound' : isCanonical(command) ? 'installed' : 'collision', key, isDefault: key === DEFAULT_KEY };
66
- }
67
- export function installInboxBinding(opts = {}) {
68
- const socket = opts.socket ?? tmuxSocketFromEnvironment();
69
- const key = opts.key ?? configuredKey();
70
- if (socket === undefined)
71
- return { state: 'unbound', key, isDefault: key === DEFAULT_KEY };
72
- const existing = rootBinding(socket, key);
73
- if (existing !== undefined && !isCanonical(existing))
74
- return { state: 'collision', key, isDefault: key === DEFAULT_KEY };
75
- if (existing === undefined)
76
- tmux(socket, ['bind-key', '-T', 'root', key, 'run-shell', '-b', bindingCommand()]);
77
- if (opts.key !== undefined) {
78
- // Switching to a new key: drop the previous configured key iff it still holds the
79
- // canonical toggle, so bindings don't accrete and inspect/unbind track a single live key.
80
- const previous = configuredKey();
81
- if (previous !== key && isCanonical(rootBinding(socket, previous)))
82
- tmux(socket, ['unbind-key', '-T', 'root', previous]);
83
- writeConfiguredKey(key);
84
- }
85
- return { state: 'installed', key, isDefault: key === DEFAULT_KEY };
86
- }
87
- export function unbindInboxBinding(socket = tmuxSocketFromEnvironment()) {
88
- const key = configuredKey();
89
- if (socket !== undefined && isCanonical(rootBinding(socket, key)))
90
- tmux(socket, ['unbind-key', '-T', 'root', key]);
91
- return inspectInboxBinding(socket);
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()];
92
35
  }
93
36
  export function tmuxSocketFromEnvironment() {
94
37
  const value = process.env['TMUX'];
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.35",
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",