@crouton-kit/humanloop 0.3.37 → 0.3.38

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.
@@ -1,11 +1,14 @@
1
1
  import type { InteractionResponse, TicketSummary } from '../types.js';
2
2
  import type { Key } from '../tui/terminal.js';
3
+ import { startWebServer } from '../browser/server.js';
3
4
  export interface InboxControllerOptions {
4
5
  roots?: string[];
5
6
  cols?: number;
6
7
  rows?: number;
7
8
  scan?: (roots?: string[]) => TicketSummary[];
8
9
  completeDeck?: (dir: string, responses: InteractionResponse[], token: string) => Promise<unknown>;
10
+ startDeckBrowser?: typeof startWebServer;
11
+ openBrowser?: (url: string) => void;
9
12
  }
10
13
  type Screen = 'list' | 'detail';
11
14
  /** One terminal owner for scanning, stable selection, claims, and frame diffs. */
@@ -19,6 +22,8 @@ export declare class InboxController {
19
22
  private screen;
20
23
  private adapter;
21
24
  private reviewAdapter;
25
+ private deckBrowser;
26
+ private deckBrowserStarting;
22
27
  private claim;
23
28
  private reconciling;
24
29
  private suspended;
@@ -51,6 +56,12 @@ export declare class InboxController {
51
56
  * in ReviewAdapter; the controller only owns the terminal handoff. */
52
57
  private activateReview;
53
58
  reloadSelectedDeck(): void;
59
+ /** Hand the selected deck to its browser surface while retaining this ticket's claim. */
60
+ private openDeckBrowser;
61
+ /** The browser has atomically published the response; reconcile its owner delivery. */
62
+ private finishDeckBrowser;
63
+ /** Return browser authority to the terminal deck without changing its draft. */
64
+ private takeBackDeckBrowser;
54
65
  close(): void;
55
66
  run(): Promise<void>;
56
67
  private complete;
@@ -2,6 +2,9 @@ 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 { startWebServer } from '../browser/server.js';
6
+ import { openBrowser } from '../browser/open.js';
7
+ import { renderHandoff } from '../tui/render.js';
5
8
  import { BOLD, CYAN, DIM, GRAY, RESET, YELLOW, clipLine } from '../tui/ansi.js';
6
9
  import { buildInboxLines } from './tui.js';
7
10
  import { inboxLayout } from './layout.js';
@@ -24,6 +27,8 @@ export class InboxController {
24
27
  screen = 'list';
25
28
  adapter;
26
29
  reviewAdapter;
30
+ deckBrowser;
31
+ deckBrowserStarting = false;
27
32
  claim;
28
33
  reconciling = false;
29
34
  suspended = false;
@@ -121,7 +126,16 @@ export class InboxController {
121
126
  this.close();
122
127
  return;
123
128
  }
129
+ if (this.deckBrowser !== undefined) {
130
+ if (input === 'w' || input === 'W')
131
+ void this.takeBackDeckBrowser();
132
+ return;
133
+ }
124
134
  if (this.screen === 'detail' && this.adapter !== undefined) {
135
+ if ((input === 'w' || input === 'W') && this.adapter.canAcceptHostKeys()) {
136
+ void this.openDeckBrowser();
137
+ return;
138
+ }
125
139
  this.adapter.handleKey(input, key);
126
140
  this.repaint();
127
141
  return;
@@ -221,11 +235,69 @@ export class InboxController {
221
235
  }
222
236
  }
223
237
  reloadSelectedDeck() { this.adapter?.reload(); this.repaint(); }
238
+ /** Hand the selected deck to its browser surface while retaining this ticket's claim. */
239
+ async openDeckBrowser() {
240
+ if (this.deckBrowser !== undefined || this.deckBrowserStarting || this.adapter === undefined || this.claim === undefined)
241
+ return;
242
+ const item = this.items[this.selectedIndex];
243
+ if (item?.kind !== 'deck' || item.dir !== this.claim.dir)
244
+ return;
245
+ const deck = readJson(deckPath(item.dir));
246
+ if (deck === null)
247
+ return;
248
+ this.deckBrowserStarting = true;
249
+ try {
250
+ const start = this.options.startDeckBrowser ?? startWebServer;
251
+ let browser;
252
+ browser = await start({
253
+ dir: item.dir,
254
+ deck,
255
+ onSubmit: () => { void this.finishDeckBrowser(item.dir, browser); },
256
+ });
257
+ this.deckBrowser = browser;
258
+ (this.options.openBrowser ?? openBrowser)(browser.url);
259
+ }
260
+ catch (error) {
261
+ this.status = error instanceof Error ? error.message : String(error);
262
+ }
263
+ finally {
264
+ this.deckBrowserStarting = false;
265
+ this.repaint();
266
+ }
267
+ }
268
+ /** The browser has atomically published the response; reconcile its owner delivery. */
269
+ async finishDeckBrowser(dir, browser) {
270
+ if (this.deckBrowser === browser)
271
+ this.deckBrowser = undefined;
272
+ await browser.stop();
273
+ if (this.claim?.dir === dir)
274
+ this.leaveDetail();
275
+ this.rescan();
276
+ this.reconcileRoots();
277
+ this.repaint();
278
+ }
279
+ /** Return browser authority to the terminal deck without changing its draft. */
280
+ async takeBackDeckBrowser() {
281
+ const browser = this.deckBrowser;
282
+ if (browser === undefined)
283
+ return;
284
+ this.deckBrowser = undefined;
285
+ try {
286
+ await browser.requestTakeBack();
287
+ await browser.stop();
288
+ }
289
+ finally {
290
+ this.repaint(true);
291
+ }
292
+ }
224
293
  close() {
225
294
  if (this.closed)
226
295
  return;
227
296
  this.closed = true;
228
297
  void this.reviewAdapter?.stop();
298
+ const browser = this.deckBrowser;
299
+ this.deckBrowser = undefined;
300
+ void browser?.stop();
229
301
  this.leaveDetail();
230
302
  for (const watcher of this.watchers)
231
303
  watcher.close();
@@ -355,6 +427,10 @@ export class InboxController {
355
427
  return { cols: Math.max(1, layout.detailWidth - 2), rows: layout.height };
356
428
  }
357
429
  detailLines(width, rows) {
430
+ if (this.deckBrowser !== undefined)
431
+ return renderHandoff(this.deckBrowser.url, width, rows);
432
+ if (this.deckBrowserStarting)
433
+ return [` ${DIM}Opening browser review…${RESET}`];
358
434
  if (this.adapter !== undefined)
359
435
  return this.adapter.render();
360
436
  const selected = this.items[this.selectedIndex];
@@ -1,7 +1,7 @@
1
1
  import { execFileSync, spawnSync } from 'node:child_process';
2
- import { existsSync, readFileSync, writeFileSync, statSync, openSync, closeSync, unlinkSync, renameSync, accessSync, realpathSync, constants, } from 'node:fs';
3
- import { fileURLToPath } from 'node:url';
4
- import { dirname, join, resolve } from 'node:path';
2
+ import { existsSync, readFileSync, writeFileSync, statSync, mkdirSync, openSync, closeSync, unlinkSync, renameSync, accessSync, realpathSync, constants, } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
5
  import stringWidth from 'string-width';
6
6
  import { TERMRENDER_VERSION } from './version.js';
7
7
  // ── The sole org-wide termrender binding ─────────────────────────────────────
@@ -10,23 +10,10 @@ import { TERMRENDER_VERSION } from './version.js';
10
10
  // TERMRENDER_VERSION, installed into a venv humanloop owns. The binary is
11
11
  // resolved by ABSOLUTE PATH inside that venv — never `$PATH` — so a user's
12
12
  // own `pip install termrender` can never shadow or break the pin.
13
- function findPkgRoot() {
14
- let dir = dirname(fileURLToPath(import.meta.url));
15
- for (let i = 0; i < 12; i++) {
16
- if (existsSync(join(dir, 'package.json')))
17
- return dir;
18
- const parent = dirname(dir);
19
- if (parent === dir)
20
- break;
21
- dir = parent;
22
- }
23
- // dist/render/termrender.js or src/render/termrender.ts → two up is pkgRoot.
24
- return resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
25
- }
26
- const PKG_ROOT = findPkgRoot();
27
- const VENV_DIR = resolve(PKG_ROOT, '.venv');
28
- const VENV_BIN = resolve(PKG_ROOT, '.venv/bin/termrender');
29
- const VENV_PYTHON = resolve(PKG_ROOT, '.venv/bin/python');
13
+ const RENDERER_CACHE_DIR = join(resolve(process.env.XDG_CACHE_HOME || join(homedir(), '.cache')), 'humanloop', 'termrender', TERMRENDER_VERSION);
14
+ const VENV_DIR = join(RENDERER_CACHE_DIR, 'venv');
15
+ const VENV_BIN = join(VENV_DIR, 'bin/termrender');
16
+ const VENV_PYTHON = join(VENV_DIR, 'bin/python');
30
17
  // Readiness marker written by the single authoritative provisioning transition
31
18
  // after a verified install. It fingerprints the ACTUAL verified environment —
32
19
  // launcher + interpreter (mtime, size, mode) and the interpreter's realpath —
@@ -38,11 +25,12 @@ const VENV_PYTHON = resolve(PKG_ROOT, '.venv/bin/python');
38
25
  // marker (see invalidateRenderer), so the next process repairs. Together these
39
26
  // remove the ~149ms `termrender -h` + `importlib.metadata` spawn tax from the
40
27
  // steady path without letting a stale marker trust a broken renderer forever.
41
- const VENV_STAMP = resolve(PKG_ROOT, '.venv/.hl-termrender-stamp.json');
28
+ const VENV_STAMP = join(VENV_DIR, '.hl-termrender-stamp.json');
42
29
  // 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');
30
+ // survives a reinstall. The user cache stays writable when humanloop is installed
31
+ // in a read-only runtime image, and serializes venv mutation + stamp publication
32
+ // across processes: a stamp can never certify a concurrently-changing venv.
33
+ const VENV_LOCK = join(RENDERER_CACHE_DIR, '.hl-termrender.lock');
46
34
  // A lock older than this is from a crashed process and may be stolen. Set
47
35
  // comfortably above the worst-case held path (uv probe 5s + venv 60s + install
48
36
  // 120s + re-verify ~10s ≈ 195s) so a slow-but-alive holder is never judged
@@ -220,6 +208,14 @@ function stealStaleLock() {
220
208
  catch { /* best effort */ }
221
209
  }
222
210
  function withProvisionLock(provision) {
211
+ try {
212
+ mkdirSync(RENDERER_CACHE_DIR, { recursive: true });
213
+ }
214
+ catch (err) {
215
+ rendererState = 'unavailable';
216
+ process.stderr.write(`[hl] termrender unavailable — renderer cache cannot be created (${err instanceof Error ? err.message : String(err)}); using plaintext fallback\n`);
217
+ return;
218
+ }
223
219
  const giveUpAt = Date.now() + LOCK_GIVE_UP_MS;
224
220
  for (;;) {
225
221
  let fd;
@@ -227,8 +223,11 @@ function withProvisionLock(provision) {
227
223
  fd = openSync(VENV_LOCK, 'wx'); // O_CREAT | O_EXCL — atomic acquire
228
224
  }
229
225
  catch (err) {
230
- if (err.code !== 'EEXIST')
231
- throw err;
226
+ if (err.code !== 'EEXIST') {
227
+ rendererState = 'unavailable';
228
+ process.stderr.write(`[hl] termrender unavailable — renderer cache cannot be locked (${err instanceof Error ? err.message : String(err)}); using plaintext fallback\n`);
229
+ return;
230
+ }
232
231
  if (lockIsStale()) {
233
232
  stealStaleLock();
234
233
  continue;
@@ -74,7 +74,7 @@ export function renderOverview(state, cols, rows) {
74
74
  lines.push('');
75
75
  }
76
76
  lines.push(` ${DIM}${hline(Math.min(cols - 4, 60))}${RESET}`);
77
- lines.push(` ${DIM}enter${RESET} review ${DIM}j/k${RESET} navigate ${DIM}q${RESET} finish`);
77
+ lines.push(` ${DIM}enter${RESET} review ${DIM}j/k${RESET} navigate ${DIM}w${RESET} browser ${DIM}q${RESET} finish`);
78
78
  while (lines.length < rows)
79
79
  lines.push('');
80
80
  // Overview content extends roughly cols-16 wide for option labels; center
@@ -272,6 +272,8 @@ export function renderItemReview(state, cols, rows) {
272
272
  if (overflows) {
273
273
  footerParts.unshift(state.inputMode ? `${DIM}pgup/pgdn${RESET} scroll` : `${DIM}u/d${RESET} scroll`);
274
274
  }
275
+ if (state.inputMode === null)
276
+ footerParts.push(`${DIM}w${RESET} browser`);
275
277
  const footer = ` ${footerParts.join(' ')}`;
276
278
  // Assemble — pad to fill rows so post-body sits at the bottom
277
279
  const lines = [...preLines, ...visibleBody, ...postLines];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/humanloop",
3
- "version": "0.3.37",
3
+ "version": "0.3.38",
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",