@crouton-kit/humanloop 0.3.9 → 0.3.11

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
@@ -5,7 +5,7 @@ import { readdirSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { resolve, join, basename } from 'node:path';
7
7
  import { execFileSync } from 'node:child_process';
8
- import { launchReview } from './editor/review.js';
8
+ import { launchReview, reviewVimscript } from './editor/review.js';
9
9
  import { validateDeck } from './inbox/deck-schema.js';
10
10
  import { ask, inbox } from './api.js';
11
11
  import { display } from './surfaces/display.js';
@@ -564,7 +564,13 @@ reviewCmd
564
564
  ' editor?: string|null, tmux?: bool=true }\n' +
565
565
  'stdout { job_id: string, output: string (absolute), follow_up: string }\n' +
566
566
  '\n' +
567
- 'Effects: spawns nvim/vim read-only; autosaves feedback JSON.\n')
567
+ 'Effects: spawns nvim/vim read-only DETACHED in a tmux pane when tmux=true\n' +
568
+ ' and $TMUX set; returns before the human starts. Writes\n' +
569
+ ' <dir>/review.vim, <dir>/feedback.json (on finish), <dir>/job.log.\n' +
570
+ ' autosaves feedback JSON; the open pane live-reloads the source on\n' +
571
+ ' disk edits. The review is open-ended (a human may take many\n' +
572
+ ' minutes) — collect via `hl job result` with wait:true, run\n' +
573
+ ' backgrounded so the call does not block on the human.\n')
568
574
  .helpOption('-h, --help', 'Show help')
569
575
  .action(async () => {
570
576
  const input = parseStdinJson();
@@ -576,15 +582,52 @@ reviewCmd
576
582
  emitError({ error: 'file_not_found', message: `File not found: ${absFile}`, field: 'file', next: 'Check the file path.' });
577
583
  }
578
584
  const output = resolve(input.output ? input.output : `${absFile}.feedback.json`);
579
- const noTmux = input.tmux === false;
580
- const jobDir = mkdtempSync(join(tmpdir(), 'hl-review-'));
585
+ const useTmux = input.tmux !== false;
586
+ // Shared job dir: the detached child reuses the parent's via input.dir;
587
+ // a top-level call mints one. The review.vim is written up front (and the
588
+ // job_started logged) so the job is recognizable as a live review — and so
589
+ // the child sources the exact vimscript — before any pane is spawned.
590
+ const jobDir = input.dir ? resolve(input.dir) : mkdtempSync(join(tmpdir(), 'hl-review-'));
591
+ mkdirSync(jobDir, { recursive: true });
581
592
  const jobId = basename(jobDir);
582
- appendJobLog(jobDir, { level: 'info', event: 'job_started', message: 'review open job started', data: { jobId, file: absFile } });
593
+ if (!input.dir) {
594
+ writeFileSync(join(jobDir, 'review.vim'), reviewVimscript());
595
+ appendJobLog(jobDir, { level: 'info', event: 'job_started', message: 'review open job started', data: { jobId, file: absFile } });
596
+ }
597
+ // tmux path: detach a child that owns the editor pane and return the handle
598
+ // now, mirroring `deck ask`. The child re-enters this leaf with tmux:false
599
+ // and the shared dir, falling through to the in-process branch below.
600
+ if (process.env['TMUX'] && useTmux) {
601
+ const scriptPath = process.argv[1];
602
+ if (!scriptPath) {
603
+ emitError({ error: 'internal', message: 'Cannot determine hl script path', next: 'Report this as a bug.' });
604
+ }
605
+ const sq = (s) => /^[a-zA-Z0-9_\-./:@%+=]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
606
+ const childInput = JSON.stringify({ file: absFile, output, editor: input.editor ?? null, tmux: false, dir: jobDir });
607
+ const cmd = `echo ${sq(childInput)} | ${sq(process.execPath)} ${sq(scriptPath)} review open`;
608
+ try {
609
+ execFileSync('tmux', ['split-window', '-d', '-h', cmd], { stdio: 'ignore' });
610
+ }
611
+ catch (spawnErr) {
612
+ const msg = spawnErr instanceof Error ? spawnErr.message : String(spawnErr);
613
+ appendJobLog(jobDir, { level: 'error', event: 'job_failed', message: `tmux spawn failed: ${msg}` });
614
+ emitError({ error: 'internal', message: `tmux spawn failed: ${msg}`, next: 'Check that $TMUX is set. Or pass tmux:false.' });
615
+ }
616
+ process.stdout.write(JSON.stringify({
617
+ job_id: jobId,
618
+ output,
619
+ follow_up: `Call hl job result with stdin {"job_id":"${jobId}","wait":true} to block until the human finishes the review. They may take many minutes — run this backgrounded; you will be notified on completion.`,
620
+ }) + '\n');
621
+ process.exit(0);
622
+ }
623
+ // In-process path: the detached child (this pane is its TTY), or a degraded
624
+ // top-level call with no tmux. launchReview blocks until the editor exits.
583
625
  try {
584
626
  const result = await launchReview(absFile, {
585
627
  output,
586
628
  editor: (input.editor && typeof input.editor === 'string') ? input.editor : undefined,
587
- noTmux,
629
+ noTmux: true,
630
+ jobDir,
588
631
  });
589
632
  appendJobLog(jobDir, { level: 'info', event: 'job_finished', message: 'review finished', data: { comments: result.comments.length } });
590
633
  writeFileSync(join(jobDir, 'feedback.json'), JSON.stringify(result, null, 2));
@@ -592,6 +635,7 @@ reviewCmd
592
635
  job_id: jobId,
593
636
  output,
594
637
  follow_up: `Call hl job result with stdin {"job_id":"${jobId}","wait":true} to retrieve the feedback.`,
638
+ ...(input.dir ? {} : { _note: 'No tmux: launchReview blocked synchronously. Result is already available.' }),
595
639
  }) + '\n');
596
640
  process.exit(0);
597
641
  }
@@ -23,7 +23,16 @@ export interface ReviewOptions {
23
23
  w: string;
24
24
  h: string;
25
25
  };
26
+ /**
27
+ * Reuse this directory for `review.vim` instead of minting a fresh temp dir.
28
+ * The async `review open` kickoff passes its job dir so the detached child
29
+ * sources the same vimscript the parent already wrote there (which also lets
30
+ * the job be recognized as a review while it's still live). When the file
31
+ * already exists it is left untouched.
32
+ */
33
+ jobDir?: string;
26
34
  }
35
+ export declare function reviewVimscript(): string;
27
36
  /**
28
37
  * Compact stdout rendering for the agent: per comment just the line:col
29
38
  * range, the original text in that span, and the note — plus the source
@@ -26,12 +26,13 @@ function resolveEditor(override) {
26
26
  // The entire review UX as a clean, minimal Vimscript config sourced via `-u`.
27
27
  // Works in both Neovim and Vim 8+. The source file is opened read-only; the
28
28
  // human anchors comments to real source lines/selections and quits to submit.
29
- function reviewVimscript() {
29
+ export function reviewVimscript() {
30
30
  return [
31
31
  `" hl propose — review layer. Runs on a CLEAN config (nvim -u NONE: no`,
32
32
  `" init.lua, no LazyVim, no plugins/keymaps). Look/feel is ONLY the user's`,
33
- `" 'gloam' colorscheme + built-in treesitter markdown highlighting, applied`,
34
- `" below. The rest is the read-only guard, comment commands, and autosave.`,
33
+ `" 'gloam' colorscheme + built-in treesitter markdown highlighting + (when`,
34
+ `" installed) render-markdown.nvim for GFM tables/headings, applied below.`,
35
+ `" The rest is the read-only guard, comment commands, and autosave.`,
35
36
  `let g:hl_out = $HL_OUTPUT`,
36
37
  `let g:hl_src = $HL_SOURCE`,
37
38
  `let s:comments = []`,
@@ -146,35 +147,96 @@ function reviewVimscript() {
146
147
  ` echo 'Removed last comment — ' . len(s:comments) . ' left'`,
147
148
  `endfunction`,
148
149
  ``,
149
- `function! s:List() abort`,
150
- ` let l:wid = bufwinid('__HL_Comments__')`,
151
- ` if l:wid != -1`,
152
- ` call win_gotoid(l:wid) | close | return`,
153
- ` endif`,
150
+ `" Build the list buffer lines plus a parallel map from buffer line -> comment`,
151
+ `" index (0-based; -1 for non-actionable rows like the quote continuation or`,
152
+ `" the empty-state hint). Stored on the buffer so the action maps can resolve`,
153
+ `" which comment the cursor is on.`,
154
+ `function! s:ListLines() abort`,
154
155
  ` let l:lines = []`,
156
+ ` let l:map = []`,
155
157
  ` if empty(s:comments)`,
156
- ` let l:lines = ['(no comments yet — select text or put the cursor on a line, then <Space>c or :HLComment)']`,
158
+ ` call add(l:lines, '(no comments yet — select text or put the cursor on a line, then <Space>c or :HLComment)')`,
159
+ ` call add(l:map, -1)`,
157
160
  ` else`,
158
- ` let l:i = 1`,
161
+ ` let l:idx = 0`,
159
162
  ` for l:c in s:comments`,
160
163
  ` let l:ln = get(l:c,'line',0)`,
161
164
  ` let l:end = get(l:c,'endLine',l:ln)`,
162
165
  ` let l:loc = l:ln == l:end ? ('L' . l:ln) : ('L' . l:ln . '-' . l:end)`,
163
- ` call add(l:lines, l:i . '. [' . l:loc . '] ' . get(l:c,'comment',''))`,
166
+ ` call add(l:lines, (l:idx+1) . '. [' . l:loc . '] ' . get(l:c,'comment',''))`,
167
+ ` call add(l:map, l:idx)`,
164
168
  ` if !empty(get(l:c,'quote',''))`,
165
169
  ` call add(l:lines, ' > ' . substitute(get(l:c,'quote',''), "\\n", ' / ', 'g'))`,
170
+ ` call add(l:map, l:idx)`,
166
171
  ` endif`,
167
- ` let l:i += 1`,
172
+ ` let l:idx += 1`,
168
173
  ` endfor`,
169
174
  ` endif`,
170
- ` botright 10split __HL_Comments__`,
171
- ` setlocal buftype=nofile bufhidden=wipe noswapfile nobuflisted`,
172
- ` setlocal nonumber nocursorline winfixheight signcolumn=no`,
175
+ ` return [l:lines, l:map]`,
176
+ `endfunction`,
177
+ ``,
178
+ `" Refill the (already-open) comments buffer in place and refresh the line map.`,
179
+ `function! s:RenderList() abort`,
180
+ ` let [l:lines, l:map] = s:ListLines()`,
181
+ ` let b:hl_map = l:map`,
173
182
  ` setlocal modifiable`,
183
+ ` silent! 1,$delete _`,
174
184
  ` call setline(1, l:lines)`,
175
185
  ` setlocal nomodifiable`,
186
+ `endfunction`,
187
+ ``,
188
+ `" Comment index (0-based) under the cursor in the comments buffer, or -1.`,
189
+ `function! s:ListIdx() abort`,
190
+ ` if !exists('b:hl_map') | return -1 | endif`,
191
+ ` let l:lnum = line('.')`,
192
+ ` if l:lnum < 1 || l:lnum > len(b:hl_map) | return -1 | endif`,
193
+ ` return b:hl_map[l:lnum - 1]`,
194
+ `endfunction`,
195
+ ``,
196
+ `function! s:ListDelete() abort`,
197
+ ` let l:idx = s:ListIdx()`,
198
+ ` if l:idx < 0`,
199
+ ` echohl WarningMsg | echo 'No comment on this line' | echohl NONE | return`,
200
+ ` endif`,
201
+ ` call remove(s:comments, l:idx)`,
202
+ ` call s:Save()`,
203
+ ` call s:Marks()`,
204
+ ` call s:RenderList()`,
205
+ ` echo 'Deleted comment — ' . len(s:comments) . ' left'`,
206
+ `endfunction`,
207
+ ``,
208
+ `function! s:ListEdit() abort`,
209
+ ` let l:idx = s:ListIdx()`,
210
+ ` if l:idx < 0`,
211
+ ` echohl WarningMsg | echo 'No comment on this line' | echohl NONE | return`,
212
+ ` endif`,
213
+ ` let l:cur = get(s:comments[l:idx], 'comment', '')`,
214
+ ` let l:txt = input('Edit comment: ', l:cur)`,
215
+ ` redraw`,
216
+ ` if empty(trim(l:txt))`,
217
+ ` echohl WarningMsg | echo 'Edit cancelled — comment unchanged' | echohl NONE | return`,
218
+ ` endif`,
219
+ ` let s:comments[l:idx]['comment'] = l:txt`,
220
+ ` call s:Save()`,
221
+ ` call s:RenderList()`,
222
+ ` echo 'Updated comment'`,
223
+ `endfunction`,
224
+ ``,
225
+ `function! s:List() abort`,
226
+ ` let l:wid = bufwinid('__HL_Comments__')`,
227
+ ` if l:wid != -1`,
228
+ ` call win_gotoid(l:wid) | close | return`,
229
+ ` endif`,
230
+ ` botright 10split __HL_Comments__`,
231
+ ` setlocal buftype=nofile bufhidden=wipe noswapfile nobuflisted`,
232
+ ` setlocal nonumber cursorline winfixheight signcolumn=no`,
233
+ ` call s:RenderList()`,
176
234
  ` nnoremap <buffer> <silent> q :close<CR>`,
177
235
  ` nnoremap <buffer> <silent> <Space>l :close<CR>`,
236
+ ` nnoremap <buffer> <silent> dd :call <SID>ListDelete()<CR>`,
237
+ ` nnoremap <buffer> <silent> e :call <SID>ListEdit()<CR>`,
238
+ ` nnoremap <buffer> <silent> <CR> :call <SID>ListEdit()<CR>`,
239
+ ` echohl Question | echo 'list — e/<CR> edit · dd delete · q/<Space>l close' | echohl NONE`,
178
240
  `endfunction`,
179
241
  ``,
180
242
  `function! s:Submit() abort`,
@@ -189,7 +251,7 @@ function reviewVimscript() {
189
251
  `command! HLList call <SID>List()`,
190
252
  `command! HLUndo call <SID>Undo()`,
191
253
  `command! HLSubmit call <SID>Submit()`,
192
- `command! HLHelp echo 'REVIEW <Space>c comment (visual or line) <Space>l list <Space>u undo-last <Space>s submit & quit or :HLComment/:HLList/:HLUndo/:HLSubmit (any quit submits)'`,
254
+ `command! HLHelp echo 'REVIEW <Space>c comment <Space>l list <Space>u undo-last <Space>s submit & quit | in list: e/<CR> edit · dd delete · q close (any quit submits)'`,
193
255
  ``,
194
256
  `" Highlights are (re)applied after any colorscheme so the user's theme`,
195
257
  `" (e.g. gloam) loads first, then our anchor highlight sits on top.`,
@@ -215,9 +277,51 @@ function reviewVimscript() {
215
277
  ` if &filetype !=# 'markdown' | setlocal filetype=markdown | endif`,
216
278
  ` " gloam only defines treesitter @markup.* highlight groups for markdown,`,
217
279
  ` " so the styling needs treesitter active. Built-in treesitter plus the`,
218
- ` " site-dir markdown parser render it with zero plugins.`,
280
+ ` " site-dir markdown parser render it with zero plugins. When the user has`,
281
+ ` " render-markdown.nvim installed (lazy dir / packpath), pull it in too so`,
282
+ ` " GFM tables and headings render the same as their normal editor — it only`,
283
+ ` " draws extmarks/conceal, so the read-only buffer text (and the line:col`,
284
+ ` " anchors comments hang off of) is never touched.`,
219
285
  ` if has('nvim')`,
220
- ` silent! lua pcall(vim.treesitter.start, 0, 'markdown')`,
286
+ ` silent! lua << HLLUA`,
287
+ `pcall(vim.treesitter.start, 0, 'markdown')`,
288
+ `pcall(function()`,
289
+ ` local path = vim.fn.stdpath('data') .. '/lazy/render-markdown.nvim'`,
290
+ ` if vim.fn.isdirectory(path) == 0 then`,
291
+ ` local hits = vim.fn.globpath(vim.o.packpath, '*/*/render-markdown.nvim', false, true)`,
292
+ ` path = hits[1]`,
293
+ ` end`,
294
+ ` if not path or vim.fn.isdirectory(path) == 0 then return end`,
295
+ ` vim.opt.runtimepath:prepend(path)`,
296
+ ` vim.g.render_markdown_config = {`,
297
+ ` file_types = { 'markdown' },`,
298
+ ` completions = { lsp = { enabled = false } },`,
299
+ ` heading = { border = false },`,
300
+ ` }`,
301
+ ` vim.cmd('silent! runtime plugin/render-markdown.lua')`,
302
+ ` -- Defer the initial paint to the next tick: during VimEnter the window`,
303
+ ` -- isn't laid out yet, so an immediate render no-ops and tables show raw`,
304
+ ` -- until the first cursor move. api.render() forces a paint for the`,
305
+ ` -- buffer's window (the live autocmds the plugin installed keep it updated`,
306
+ ` -- on edits/scroll thereafter). render-markdown refuses to draw while a`,
307
+ ` -- prompt mode is active ('r' hit-enter, 'rm' more, 'r?' confirm) — e.g. a`,
308
+ ` -- wrapped :echo — so re-defer until we're back in a normal state.`,
309
+ ` local function paint()`,
310
+ ` local m = vim.api.nvim_get_mode().mode`,
311
+ ` if m == 'r' or m == 'rm' or m == 'r?' then`,
312
+ ` vim.defer_fn(paint, 60)`,
313
+ ` return`,
314
+ ` end`,
315
+ ` pcall(function()`,
316
+ ` require('render-markdown.api').render({`,
317
+ ` buf = vim.api.nvim_get_current_buf(),`,
318
+ ` win = vim.api.nvim_get_current_win(),`,
319
+ ` })`,
320
+ ` end)`,
321
+ ` end`,
322
+ ` vim.schedule(paint)`,
323
+ `end)`,
324
+ `HLLUA`,
221
325
  ` endif`,
222
326
  ` " Buffer-local <Space> maps. Clean config has no which-key/<leader>`,
223
327
  ` " bindings to collide with, and these are gone outside this buffer.`,
@@ -230,11 +334,29 @@ function reviewVimscript() {
230
334
  ` call s:Load()`,
231
335
  ` call s:Marks()`,
232
336
  ` redraw`,
233
- ` echohl Question | echo 'hl review <Space>c comment <Space>l list <Space>u undo <Space>s submit & quit (:HLHelp)' | echohl NONE`,
337
+ ` " Keep this hint short: a line that wraps past the cmdline triggers the`,
338
+ ` " hit-enter prompt, whose mode blocks the deferred render-markdown paint.`,
339
+ ` " The full key list lives in :HLHelp (and is printed to stderr on launch).`,
340
+ ` echohl Question | echo 'hl review — <Space>c comment · l list · u undo · s submit (:HLHelp)' | echohl NONE`,
234
341
  `endfunction`,
235
342
  `autocmd VimEnter * call s:Setup()`,
236
343
  `autocmd VimLeavePre * call s:Save()`,
237
344
  ``,
345
+ `" Watchmode: the source is opened read-only here but may be rewritten on`,
346
+ `" disk by the agent while the human reviews. autoread + a periodic checktime`,
347
+ `" (timers fire even when the tmux pane is unfocused) reload the buffer`,
348
+ `" silently — it is never locally modified, so there is nothing to clobber.`,
349
+ `" On reload, re-run Setup (read-only guard, treesitter, maps) and redraw the`,
350
+ `" comment anchors, which the reload cleared. Comments live in s:comments /`,
351
+ `" the autosave file, not the buffer, so they survive; their line anchors may`,
352
+ `" drift if the edit moved text, which is expected — the human sees the latest.`,
353
+ `set autoread`,
354
+ `autocmd FocusGained,BufEnter,CursorHold * silent! checktime`,
355
+ `autocmd FileChangedShellPost * call s:Setup() | call s:Marks() | redraw`,
356
+ `if exists('*timer_start')`,
357
+ ` let s:hl_watch = timer_start(1000, {-> execute('silent! checktime')}, {'repeat': -1})`,
358
+ `endif`,
359
+ ``,
238
360
  ].join('\n');
239
361
  }
240
362
  function atomicWrite(path, data) {
@@ -384,9 +506,12 @@ export async function launchReview(file, opts) {
384
506
  }
385
507
  const outPath = resolve(opts.output);
386
508
  const bin = resolveEditor(opts.editor);
387
- const dir = mkdtempSync(join(tmpdir(), 'hl-review-'));
509
+ // When the async kickoff passes its job dir, source the review.vim the parent
510
+ // already wrote there; otherwise mint a private temp dir as before.
511
+ const dir = opts.jobDir ?? mkdtempSync(join(tmpdir(), 'hl-review-'));
388
512
  const initPath = join(dir, 'review.vim');
389
- writeFileSync(initPath, reviewVimscript());
513
+ if (!existsSync(initPath))
514
+ writeFileSync(initPath, reviewVimscript());
390
515
  const env = {
391
516
  ...process.env,
392
517
  HL_OUTPUT: outPath,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/humanloop",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
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",