@crouton-kit/humanloop 0.3.2 → 0.3.4

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/api.js CHANGED
@@ -55,13 +55,15 @@ export async function ask(deck, opts = {}) {
55
55
  const dir = opts.dir ?? managedDir();
56
56
  mkdirSync(dir, { recursive: true });
57
57
  atomicWriteJson(deckPath(dir), deck);
58
- const { responses, completedAt, responsePath } = await resolveInteractionDir(dir, deck, {
58
+ const { responses, completedAt, responsePath, deck: answeredDeck } = await resolveInteractionDir(dir, deck, {
59
59
  sessionId: opts.sessionId,
60
60
  cols: opts.cols,
61
61
  rows: opts.rows,
62
62
  });
63
63
  return {
64
- summary: buildSummary(deck, responses),
64
+ // `answeredDeck` === `deck` unless an agent ran `hl deck update`
65
+ // mid-flight; the summary must describe the questions actually answered.
66
+ summary: buildSummary(answeredDeck, responses),
65
67
  responsePath,
66
68
  schema: RESPONSE_SCHEMA_ID,
67
69
  responses,
package/dist/cli.js CHANGED
@@ -313,7 +313,17 @@ program
313
313
  .helpOption('-h, --help', 'Show help')
314
314
  .addHelpCommand(false);
315
315
  // ── deck ──────────────────────────────────────────────────────────────────────
316
- const deckCmd = program.command('deck').description('Write questions, get answers from the human.');
316
+ const deckCmd = program.command('deck').description('Write questions, get answers from the human.\n' +
317
+ '\n' +
318
+ 'Children:\n' +
319
+ ' hl deck ask — spawn the decisions TUI, return a job handle | use when: posing material decisions\n' +
320
+ ' hl deck update — replace the deck of a LIVE ask job in place | use when: the questions changed after ask\n' +
321
+ ' hl deck validate — preflight a deck object, no side effects | use when: checking a deck before ask\n' +
322
+ '\n' +
323
+ 'A `deck update` rewrites the live job\'s deck.json; the TUI pane the\n' +
324
+ 'human is looking at reloads it automatically within ~1s (answers whose\n' +
325
+ 'interaction ids still exist are kept). Read this leaf\'s -h before calling\n' +
326
+ 'it — it mutates a session a human is actively in.');
317
327
  deckCmd
318
328
  .command('ask')
319
329
  .description('Kickoff: spawn the decisions TUI and return immediately.\n' +
@@ -324,7 +334,9 @@ deckCmd
324
334
  '\n' +
325
335
  'Effects: writes <dir>/deck.json, <dir>/progress.json (live),\n' +
326
336
  ' <dir>/response.json (on finish), <dir>/job.log (JSONL).\n' +
327
- ' Spawns TUI detached in a tmux pane when tmux=true and $TMUX set.\n')
337
+ ' Spawns TUI detached in a tmux pane when tmux=true and $TMUX set.\n' +
338
+ ' While the job is live the TUI watches <dir>/deck.json: a later\n' +
339
+ ' `hl deck update` rewrites it and the pane reloads automatically.\n')
328
340
  .helpOption('-h, --help', 'Show help')
329
341
  .action(async () => {
330
342
  const input = parseStdinJson();
@@ -407,7 +419,7 @@ deckCmd
407
419
  process.stdout.write(JSON.stringify({
408
420
  job_id: jobId,
409
421
  dir,
410
- follow_up: `Call hl job result with stdin {"job_id":"${jobId}","wait":true} to block until the human finishes.`,
422
+ follow_up: `Call hl job result with stdin {"job_id":"${jobId}","wait":true} to block until the human finishes. If the questions change before they answer, pipe {"job_id":"${jobId}","deck":{...}} to hl deck update — the pane reloads automatically.`,
411
423
  }) + '\n');
412
424
  process.exit(0);
413
425
  }
@@ -435,6 +447,74 @@ deckCmd
435
447
  });
436
448
  }
437
449
  });
450
+ deckCmd
451
+ .command('update')
452
+ .description('Replace the deck of a LIVE ask job; the human\'s TUI pane reloads.\n' +
453
+ '\n' +
454
+ 'stdin { job_id: string (required), deck: object (required) }\n' +
455
+ 'stdout { ok: true, job_id: string, interactions: int, follow_up: string }\n' +
456
+ '\n' +
457
+ 'The TUI watches deck.json and reloads within ~1s of this write. Answers\n' +
458
+ 'whose interaction id still exists in the new deck are preserved; new or\n' +
459
+ 'id-changed interactions appear unanswered. In-flight unsubmitted input\n' +
460
+ '(a comment being typed) is discarded on reload.\n' +
461
+ '\n' +
462
+ 'Errors: job_not_found (no such job_id) | job_not_live (already\n' +
463
+ 'done/failed/canceled — nothing to reload) | deck_invalid (deck rejected;\n' +
464
+ 'the old deck stays in place, run hl deck validate first).\n' +
465
+ '\n' +
466
+ 'Effects: atomically rewrites <dir>/deck.json; appends a deck_updated\n' +
467
+ 'event to <dir>/job.log. No effect on response.json/progress.json.\n')
468
+ .helpOption('-h, --help', 'Show help')
469
+ .action(() => {
470
+ const input = parseStdinJson();
471
+ if (!input.job_id || typeof input.job_id !== 'string') {
472
+ emitError({ error: 'bad_input', message: 'job_id is required', field: 'job_id', next: 'Provide: {"job_id": "<id>", "deck": {...}}' });
473
+ }
474
+ if (!input.deck || typeof input.deck !== 'object') {
475
+ emitError({ error: 'bad_input', message: 'deck is required and must be an object', field: 'deck', next: "Run: echo '{\"kind\":\"deck\"}' | hl schema show" });
476
+ }
477
+ const dir = resolveJobDir(input.job_id);
478
+ if (!existsSync(dir) || !existsSync(deckPath(dir))) {
479
+ emitError({ error: 'job_not_found', message: `Job not found: ${input.job_id}`, next: 'Check the job_id returned by hl deck ask.' });
480
+ }
481
+ const state = detectJobState(dir);
482
+ if (state !== 'live') {
483
+ emitError({
484
+ error: 'job_not_live',
485
+ message: `Job is ${state}; its deck can no longer be reloaded.`,
486
+ received: state,
487
+ next: 'The human already finished. Start a fresh deck with hl deck ask.',
488
+ });
489
+ }
490
+ let deck;
491
+ try {
492
+ const _v = {};
493
+ void _v;
494
+ deck = validateDeck(input.deck);
495
+ }
496
+ catch (validationErr) {
497
+ emitError({
498
+ error: 'deck_invalid',
499
+ message: `deck validation failed: ${validationErr instanceof Error ? validationErr.message : String(validationErr)}`,
500
+ received: input.deck,
501
+ next: "The live deck is unchanged. Fix the deck, then: echo '{\"deck\":{...}}' | hl deck validate",
502
+ });
503
+ }
504
+ atomicWriteJson(deckPath(dir), deck);
505
+ appendJobLog(dir, {
506
+ level: 'info', event: 'deck_updated',
507
+ message: `deck replaced (${deck.interactions.length} interaction(s)); pane reloads on next watch tick`,
508
+ data: { jobId: basename(dir), interactions: deck.interactions.length },
509
+ });
510
+ process.stdout.write(JSON.stringify({
511
+ ok: true,
512
+ job_id: basename(dir),
513
+ interactions: deck.interactions.length,
514
+ follow_up: `The pane reloads within ~1s. Still resolve with hl job result {"job_id":"${basename(dir)}","wait":true}.`,
515
+ }) + '\n');
516
+ process.exit(0);
517
+ });
438
518
  deckCmd
439
519
  .command('validate')
440
520
  .description('Preflight deck validation — no side effects.\n' +
package/dist/tui/app.d.ts CHANGED
@@ -18,11 +18,18 @@ export interface ResolveDirOpts {
18
18
  * with skips) write `<dir>/response.json` atomically and drop the progress
19
19
  * file. A hard process kill leaves `progress.json` for a later resume —
20
20
  * `tryResume` (unchanged logic) reads the new dir-derived path.
21
+ *
22
+ * While the panel is mounted, `<dir>/deck.json` is polled for changes (an
23
+ * agent calling `hl deck update`). On a valid rewrite the panel is reloaded
24
+ * in place via `loadDeck`, so the human's pane reflects the new questions
25
+ * without a respawn; answers for surviving interaction ids are kept. The
26
+ * returned `deck` is the one actually answered (post-reload).
21
27
  */
22
28
  export declare function resolveInteractionDir(dir: string, deck: Deck, opts?: ResolveDirOpts): Promise<{
23
29
  responses: InteractionResponse[];
24
30
  completedAt: string;
25
31
  responsePath: string;
32
+ deck: Deck;
26
33
  }>;
27
34
  export declare function launchTui(decisionsPath: string, sessionId?: string): Promise<{
28
35
  responses: InteractionResponse[];
package/dist/tui/app.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync, existsSync, writeFileSync, renameSync, unlinkSync } from 'fs';
1
+ import { readFileSync, existsSync, writeFileSync, renameSync, unlinkSync, statSync } from 'fs';
2
2
  import { dirname, resolve as resolvePath } from 'node:path';
3
3
  import { setupTerminal, restoreTerminal, parseKeypress, getTerminalSize } from './terminal.js';
4
4
  import { diffFrame, renderOverview, renderItemReview, renderFinal } from './render.js';
@@ -6,7 +6,7 @@ import { handleKeypress, assignShortcuts } from './input.js';
6
6
  import { readConversation } from '../conversation/reader.js';
7
7
  import { defaultGenerateVisual } from '../visuals/generate.js';
8
8
  import { validateDeck } from '../inbox/deck-schema.js';
9
- import { progressPath as progressPathFor, writeResponse, clearProgress } from '../inbox/convention.js';
9
+ import { progressPath as progressPathFor, deckPath as deckPathFor, writeResponse, clearProgress } from '../inbox/convention.js';
10
10
  /** Validate an arbitrary parsed value as a Deck. Delegates to the canonical
11
11
  * Zod validator in `inbox/deck-schema.ts` (the single source of truth shared
12
12
  * with sisyphus). Kept exported for back-compat. */
@@ -179,6 +179,11 @@ export function mountPanel(opts) {
179
179
  return false;
180
180
  return internals.state.inputMode === null;
181
181
  },
182
+ atDeckTop() {
183
+ if (!internals.mounted)
184
+ return true;
185
+ return internals.state.phase === 'overview' && internals.state.inputMode === null;
186
+ },
182
187
  };
183
188
  }
184
189
  /**
@@ -187,6 +192,12 @@ export function mountPanel(opts) {
187
192
  * with skips) write `<dir>/response.json` atomically and drop the progress
188
193
  * file. A hard process kill leaves `progress.json` for a later resume —
189
194
  * `tryResume` (unchanged logic) reads the new dir-derived path.
195
+ *
196
+ * While the panel is mounted, `<dir>/deck.json` is polled for changes (an
197
+ * agent calling `hl deck update`). On a valid rewrite the panel is reloaded
198
+ * in place via `loadDeck`, so the human's pane reflects the new questions
199
+ * without a respawn; answers for surviving interaction ids are kept. The
200
+ * returned `deck` is the one actually answered (post-reload).
190
201
  */
191
202
  export async function resolveInteractionDir(dir, deck, opts = {}) {
192
203
  let conversationContext = '';
@@ -212,6 +223,12 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
212
223
  let prevFrameLocal = [];
213
224
  let lastResponses = [];
214
225
  let onData;
226
+ // The deck the human is actually answering. An agent may replace it
227
+ // mid-flight via `hl deck update` (atomic deck.json rewrite); the poller
228
+ // below reloads the panel in place and tracks the live deck here so the
229
+ // returned envelope/summary describes what was answered, not the kickoff.
230
+ let currentDeck = deck;
231
+ let deckWatch = null;
215
232
  const flushHost = (lines) => {
216
233
  const { rows: currentRows } = getTerminalSize();
217
234
  const { writes, nextPrevFrame } = diffFrame(prevFrameLocal, lines, currentRows);
@@ -222,6 +239,10 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
222
239
  prevFrameLocal = nextPrevFrame;
223
240
  };
224
241
  const finalize = (responses) => {
242
+ if (deckWatch !== null) {
243
+ clearInterval(deckWatch);
244
+ deckWatch = null;
245
+ }
225
246
  restoreTerminal();
226
247
  process.stdin.removeListener('data', onData);
227
248
  panel?.unmount();
@@ -229,7 +250,7 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
229
250
  // Resolved supersedes in-progress: write response.json, drop progress.json.
230
251
  const rp = writeResponse(dir, responses, completedAt);
231
252
  clearProgress(dir);
232
- resolve({ responses, completedAt, responsePath: rp });
253
+ resolve({ responses, completedAt, responsePath: rp, deck: currentDeck });
233
254
  };
234
255
  panel = mountPanel({
235
256
  deck,
@@ -248,6 +269,49 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
248
269
  },
249
270
  });
250
271
  flushHost(panel.render());
272
+ // ── Live deck reload ──────────────────────────────────────────────────
273
+ // Poll deck.json mtime (cheap stat; full read only on change). atomicWrite
274
+ // does write-tmp + rename, so stat/read always see a whole file — no
275
+ // fs.watch rename flakiness. The TUI never writes deck.json, so there is
276
+ // no feedback loop. A structurally identical rewrite is ignored so a
277
+ // no-op touch never disrupts the human mid-answer.
278
+ const deckFile = deckPathFor(dir);
279
+ const deckMtime = () => {
280
+ try {
281
+ return statSync(deckFile).mtimeMs;
282
+ }
283
+ catch {
284
+ return 0;
285
+ }
286
+ };
287
+ let lastDeckMtime = deckMtime();
288
+ let lastDeckJson = JSON.stringify(currentDeck);
289
+ deckWatch = setInterval(() => {
290
+ if (panel === null)
291
+ return;
292
+ const m = deckMtime();
293
+ if (m === 0 || m === lastDeckMtime)
294
+ return;
295
+ lastDeckMtime = m;
296
+ let nextDeck;
297
+ try {
298
+ const parsed = JSON.parse(readFileSync(deckFile, 'utf8'));
299
+ nextDeck = validateDeck(parsed);
300
+ }
301
+ catch {
302
+ // Mid-rename, invalid, or rejected by schema: keep the live deck,
303
+ // retry on the next tick. `hl deck update` validates before writing,
304
+ // so a persistently bad file is an out-of-band edit, not our concern.
305
+ return;
306
+ }
307
+ const nextJson = JSON.stringify(nextDeck);
308
+ if (nextJson === lastDeckJson)
309
+ return; // touch / identical content
310
+ lastDeckJson = nextJson;
311
+ currentDeck = nextDeck;
312
+ panel.loadDeck(nextDeck, { progressPath: progressPathFor(dir) });
313
+ flushHost(panel.render());
314
+ }, 500);
251
315
  onData = (data) => {
252
316
  const { input: inp, key } = parseKeypress(data);
253
317
  panel.handleKey(inp, key);
package/dist/tui/input.js CHANGED
@@ -117,7 +117,8 @@ function handleItemReview(input, key, state, render) {
117
117
  render();
118
118
  return;
119
119
  }
120
- if (input === 'q') {
120
+ // q / Esc step back to the deck overview (one level up from a card).
121
+ if (input === 'q' || key.escape) {
121
122
  state.phase = 'overview';
122
123
  render();
123
124
  return;
@@ -261,6 +262,11 @@ function handleInputMode(input, key, state, render) {
261
262
  render();
262
263
  return;
263
264
  }
265
+ if (key.backspace && key.meta) {
266
+ mode.buffer = deleteWordBack(mode.buffer);
267
+ render();
268
+ return;
269
+ }
264
270
  if (key.backspace) {
265
271
  const chars = [...mode.buffer];
266
272
  chars.pop();
@@ -277,11 +283,23 @@ function handleInputMode(input, key, state, render) {
277
283
  render();
278
284
  }
279
285
  }
286
+ function deleteWordBack(buffer) {
287
+ const chars = [...buffer];
288
+ while (chars.length > 0 && /\s/.test(chars[chars.length - 1]))
289
+ chars.pop();
290
+ while (chars.length > 0 && !/\s/.test(chars[chars.length - 1]))
291
+ chars.pop();
292
+ return chars.join('');
293
+ }
280
294
  // ── Final ────────────────────────────────────────────────────────────────────
281
295
  function handleFinal(input, key, state, render, exit) {
282
296
  if (key.return) {
283
297
  exit();
284
298
  }
299
+ else if (key.escape) {
300
+ state.phase = 'overview';
301
+ render();
302
+ }
285
303
  else if (input === 'p') {
286
304
  state.phase = 'item-review';
287
305
  state.currentIndex = state.interactions.length - 1;
@@ -4,6 +4,7 @@ export interface Key {
4
4
  return: boolean;
5
5
  escape: boolean;
6
6
  ctrl: boolean;
7
+ meta: boolean;
7
8
  tab: boolean;
8
9
  backspace: boolean;
9
10
  }
@@ -5,6 +5,7 @@ function emptyKey() {
5
5
  return: false,
6
6
  escape: false,
7
7
  ctrl: false,
8
+ meta: false,
8
9
  tab: false,
9
10
  backspace: false,
10
11
  };
@@ -24,6 +25,13 @@ export function parseKeypress(data) {
24
25
  key.return = true;
25
26
  return { input: '', key };
26
27
  }
28
+ // Alt+Backspace: terminals send ESC followed by DEL/BS. Must precede the
29
+ // bare-ESC check so the two-byte sequence isn't swallowed as plain escape.
30
+ if (str === '\x1b\x7f' || str === '\x1b\b') {
31
+ key.meta = true;
32
+ key.backspace = true;
33
+ return { input: '', key };
34
+ }
27
35
  if (str === '\x1b') {
28
36
  key.escape = true;
29
37
  return { input: '', key };
package/dist/types.d.ts CHANGED
@@ -156,4 +156,10 @@ export interface MountedPanel {
156
156
  progressPath?: string;
157
157
  }): void;
158
158
  canAcceptHostKeys(): boolean;
159
+ /**
160
+ * True when the deck is at its top level: overview phase with no active
161
+ * comment/freetext input. A host that owns mount/unmount uses this to decide
162
+ * whether Esc should step back inside the deck (false) or tear it down (true).
163
+ */
164
+ atDeckTop(): boolean;
159
165
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/humanloop",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
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",