@crouton-kit/humanloop 0.1.0 → 0.1.2

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
@@ -139,6 +139,9 @@ program
139
139
  else if (msg.includes('JSON')) {
140
140
  process.stderr.write('\nFix: the decisions file must be valid JSON matching `hl schema`.\n');
141
141
  }
142
+ else if (msg.startsWith('questions[') || msg.includes('Duplicate question id') || msg.includes('must be')) {
143
+ process.stderr.write('\nFix: the decisions file must match `hl schema`. Run `hl schema` to see the required shape.\n');
144
+ }
142
145
  process.exit(1);
143
146
  }
144
147
  });
package/dist/tui/app.d.ts CHANGED
@@ -1,2 +1,3 @@
1
- import type { DecisionsOutput } from '../types.js';
1
+ import type { DecisionsInput, DecisionsOutput } from '../types.js';
2
+ export declare function validateInput(parsed: unknown): DecisionsInput;
2
3
  export declare function launchTui(decisionsPath: string, sessionId?: string): Promise<DecisionsOutput>;
package/dist/tui/app.js CHANGED
@@ -4,15 +4,81 @@ import { flush, renderOverview, renderItemReview, renderFinal } from './render.j
4
4
  import { handleKeypress } from './input.js';
5
5
  import { readConversation } from '../conversation/reader.js';
6
6
  import { generateVisuals } from '../visuals/generate.js';
7
+ // Validate the parsed JSON before opening the terminal so bad agent input
8
+ // fails with a clear error instead of crashing inside the TUI.
9
+ export function validateInput(parsed) {
10
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
11
+ throw new Error('Decisions file must be a JSON object with a `questions` array');
12
+ }
13
+ const obj = parsed;
14
+ if (!Array.isArray(obj.questions)) {
15
+ throw new Error('`questions` must be an array');
16
+ }
17
+ if (obj.questions.length === 0) {
18
+ throw new Error('No questions in decisions file');
19
+ }
20
+ if (obj.title !== undefined && typeof obj.title !== 'string') {
21
+ throw new Error('`title` must be a string when present');
22
+ }
23
+ const seen = new Set();
24
+ const validated = [];
25
+ for (let i = 0; i < obj.questions.length; i++) {
26
+ const q = obj.questions[i];
27
+ const where = `questions[${i}]`;
28
+ if (typeof q !== 'object' || q === null || Array.isArray(q)) {
29
+ throw new Error(`${where} must be an object`);
30
+ }
31
+ if (typeof q.id !== 'string' || q.id === '') {
32
+ throw new Error(`${where}.id must be a non-empty string`);
33
+ }
34
+ if (seen.has(q.id)) {
35
+ throw new Error(`Duplicate question id: ${JSON.stringify(q.id)}`);
36
+ }
37
+ seen.add(q.id);
38
+ if (q.type === 'validation') {
39
+ if (typeof q.statement !== 'string')
40
+ throw new Error(`${where}.statement must be a string`);
41
+ if (typeof q.rationale !== 'string')
42
+ throw new Error(`${where}.rationale must be a string`);
43
+ validated.push({ id: q.id, type: 'validation', statement: q.statement, rationale: q.rationale });
44
+ }
45
+ else if (q.type === 'choice') {
46
+ if (typeof q.question !== 'string')
47
+ throw new Error(`${where}.question must be a string`);
48
+ if (typeof q.rationale !== 'string')
49
+ throw new Error(`${where}.rationale must be a string`);
50
+ if (!Array.isArray(q.options))
51
+ throw new Error(`${where}.options must be an array`);
52
+ if (q.options.length < 2)
53
+ throw new Error(`${where}.options must have at least 2 items (got ${q.options.length})`);
54
+ const opts = [];
55
+ for (let j = 0; j < q.options.length; j++) {
56
+ if (typeof q.options[j] !== 'string')
57
+ throw new Error(`${where}.options[${j}] must be a string`);
58
+ opts.push(q.options[j]);
59
+ }
60
+ validated.push({ id: q.id, type: 'choice', question: q.question, rationale: q.rationale, options: opts });
61
+ }
62
+ else if (q.type === 'freetext') {
63
+ if (typeof q.question !== 'string')
64
+ throw new Error(`${where}.question must be a string`);
65
+ if (typeof q.rationale !== 'string')
66
+ throw new Error(`${where}.rationale must be a string`);
67
+ validated.push({ id: q.id, type: 'freetext', question: q.question, rationale: q.rationale });
68
+ }
69
+ else {
70
+ throw new Error(`${where}.type must be "validation" | "choice" | "freetext" (got ${JSON.stringify(q.type)})`);
71
+ }
72
+ }
73
+ return { title: obj.title, questions: validated };
74
+ }
7
75
  export async function launchTui(decisionsPath, sessionId) {
8
76
  if (!existsSync(decisionsPath)) {
9
77
  throw new Error(`Decisions file not found: ${decisionsPath}`);
10
78
  }
11
79
  const raw = readFileSync(decisionsPath, 'utf8');
12
- const input = JSON.parse(raw);
13
- if (!input.questions || input.questions.length === 0) {
14
- throw new Error('No questions in decisions file');
15
- }
80
+ const parsed = JSON.parse(raw);
81
+ const input = validateInput(parsed);
16
82
  const state = {
17
83
  phase: 'overview',
18
84
  currentIndex: 0,
package/dist/tui/input.js CHANGED
@@ -181,12 +181,23 @@ function handleInputMode(input, key, state, render) {
181
181
  return;
182
182
  }
183
183
  if (key.backspace) {
184
- mode.buffer = mode.buffer.slice(0, -1);
184
+ // Drop the last *codepoint*, not the last UTF-16 code unit, so backspace
185
+ // on an emoji removes the whole glyph instead of leaving a lone surrogate.
186
+ const chars = [...mode.buffer];
187
+ chars.pop();
188
+ mode.buffer = chars.join('');
185
189
  render();
186
190
  return;
187
191
  }
188
- if (input.length === 1 && input.charCodeAt(0) >= 32) {
189
- mode.buffer += input;
192
+ // Accept any printable input including pasted multi-char chunks and
193
+ // multi-byte UTF-8 (emoji / CJK). Strip control bytes (ESC sequences,
194
+ // bracketed-paste markers, BEL, BS, CR) so they can't corrupt the TUI.
195
+ const cleaned = input
196
+ .replace(/\x1b\[20[01]~/g, '') // bracketed-paste start/end markers
197
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '') // CSI sequences
198
+ .replace(/[\x00-\x1F\x7F]/g, ''); // C0 controls and DEL
199
+ if (cleaned.length > 0) {
200
+ mode.buffer += cleaned;
190
201
  render();
191
202
  }
192
203
  }
@@ -1,4 +1,5 @@
1
1
  import type { TuiState } from '../types.js';
2
+ export declare function sanitize(text: string): string;
2
3
  export declare function flush(lines: string[]): void;
3
4
  export declare function renderOverview(state: TuiState): string[];
4
5
  export declare function renderItemReview(state: TuiState): string[];
@@ -14,18 +14,38 @@ const CYAN = `${ESC}36m`;
14
14
  const GRAY = `${ESC}90m`;
15
15
  const BG_BLUE = `${ESC}44m`;
16
16
  const WHITE = `${ESC}37m`;
17
+ // Strip ANSI escape sequences and other C0/C1 control bytes from user-supplied
18
+ // text so it can't poison the alt-screen buffer (cursor moves, color bleed,
19
+ // embedded \x1b[2J that clears the screen, etc). Keeps \n and \t which the
20
+ // wrappers handle explicitly.
21
+ const CONTROL_CHARS_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b[@-_]|[\x00-\x08\x0B\x0E-\x1F\x7F-\x9F]/g;
22
+ export function sanitize(text) {
23
+ if (typeof text !== 'string')
24
+ return '';
25
+ return text.replace(CONTROL_CHARS_RE, '');
26
+ }
27
+ // For one-line displays (overview rows, summaries): collapse all whitespace
28
+ // — including newlines and tabs — to single spaces so the row stays one line.
29
+ function singleLine(text) {
30
+ return sanitize(text).replace(/\s+/g, ' ').trim();
31
+ }
17
32
  function truncate(text, maxWidth) {
33
+ if (maxWidth < 1)
34
+ return '';
18
35
  if (stringWidth(text) <= maxWidth)
19
36
  return text;
37
+ // Iterate by codepoint, not UTF-16 code unit, so surrogate pairs don't split.
38
+ const chars = [...text];
20
39
  let w = 0;
21
- let i = 0;
22
- for (; i < text.length; i++) {
23
- const cw = stringWidth(text[i]);
40
+ let out = '';
41
+ for (const ch of chars) {
42
+ const cw = stringWidth(ch);
24
43
  if (w + cw + 1 > maxWidth)
25
44
  break;
45
+ out += ch;
26
46
  w += cw;
27
47
  }
28
- return text.slice(0, i) + '…';
48
+ return out + '…';
29
49
  }
30
50
  function padRight(text, width) {
31
51
  const w = stringWidth(text);
@@ -34,26 +54,67 @@ function padRight(text, width) {
34
54
  return text + ' '.repeat(width - w);
35
55
  }
36
56
  function hline(width, char = '─') {
57
+ if (width < 1)
58
+ return '';
37
59
  return char.repeat(width);
38
60
  }
61
+ // Word-wrap that ALSO respects \n as a hard break and ALSO breaks oversized
62
+ // words at maxWidth so a single 200-char token doesn't overflow the frame.
39
63
  function wrap(text, maxWidth) {
40
- const words = text.split(/\s+/);
41
- const lines = [];
42
- let current = '';
43
- for (const word of words) {
44
- const candidate = current ? `${current} ${word}` : word;
45
- if (stringWidth(candidate) <= maxWidth) {
46
- current = candidate;
64
+ if (maxWidth < 1)
65
+ return [text];
66
+ const out = [];
67
+ const paragraphs = text.split('\n');
68
+ for (let p = 0; p < paragraphs.length; p++) {
69
+ const para = paragraphs[p];
70
+ if (para === '') {
71
+ out.push('');
72
+ continue;
47
73
  }
48
- else {
49
- if (current)
50
- lines.push(current);
51
- current = word;
74
+ const words = para.split(/[ \t]+/).filter(Boolean);
75
+ let current = '';
76
+ for (let word of words) {
77
+ // Hard-break a word that's wider than the line.
78
+ while (stringWidth(word) > maxWidth) {
79
+ if (current) {
80
+ out.push(current);
81
+ current = '';
82
+ }
83
+ const piece = sliceByWidth(word, maxWidth);
84
+ out.push(piece);
85
+ word = word.slice(piece.length);
86
+ }
87
+ const candidate = current ? `${current} ${word}` : word;
88
+ if (stringWidth(candidate) <= maxWidth) {
89
+ current = candidate;
90
+ }
91
+ else {
92
+ if (current)
93
+ out.push(current);
94
+ current = word;
95
+ }
52
96
  }
97
+ if (current)
98
+ out.push(current);
53
99
  }
54
- if (current)
55
- lines.push(current);
56
- return lines.length > 0 ? lines : [''];
100
+ return out.length > 0 ? out : [''];
101
+ }
102
+ // Take the longest prefix of `s` whose visible width is <= maxWidth.
103
+ function sliceByWidth(s, maxWidth) {
104
+ let w = 0;
105
+ let out = '';
106
+ for (const ch of s) {
107
+ const cw = stringWidth(ch);
108
+ if (w + cw > maxWidth)
109
+ break;
110
+ out += ch;
111
+ w += cw;
112
+ }
113
+ // Always advance at least one character so we don't loop forever on
114
+ // a single zero-width or oversized glyph.
115
+ if (out === '' && s.length > 0)
116
+ out = [...s][0];
117
+ return out;
57
118
  }
58
119
  function hardWrap(text, maxWidth) {
59
120
  if (maxWidth < 1)
@@ -67,7 +128,8 @@ function hardWrap(text, maxWidth) {
67
128
  }
68
129
  let current = '';
69
130
  let currentW = 0;
70
- for (const ch of seg) {
131
+ // Iterate by codepoint so emoji surrogate pairs stay intact.
132
+ for (const ch of [...seg]) {
71
133
  const cw = stringWidth(ch);
72
134
  if (currentW + cw > maxWidth) {
73
135
  out.push(current);
@@ -107,25 +169,62 @@ export function renderOverview(state) {
107
169
  lines.push(` ${title} ${DIM}${progress}${RESET}`);
108
170
  lines.push(` ${DIM}${hline(Math.min(cols - 4, 60))}${RESET}`);
109
171
  lines.push('');
172
+ const rowsBuf = [];
110
173
  for (let i = 0; i < state.questions.length; i++) {
111
174
  const q = state.questions[i];
112
175
  const answer = state.answers.get(q.id);
113
176
  const icon = answer ? `${GREEN}✓${RESET}` : `${DIM}○${RESET}`;
114
- const label = q.type === 'validation' ? q.statement : q.question;
177
+ const label = singleLine(q.type === 'validation' ? q.statement : q.question);
115
178
  const typeTag = `${DIM}[${q.type}]${RESET}`;
116
179
  const cursor = i === state.currentIndex ? `${CYAN}▸${RESET} ` : ' ';
117
- lines.push(` ${cursor}${icon} ${truncate(label, cols - 20)} ${typeTag}`);
180
+ const labelMax = Math.max(10, cols - 20);
181
+ rowsBuf.push({
182
+ line: ` ${cursor}${icon} ${truncate(label, labelMax)} ${typeTag}`,
183
+ questionIndex: i,
184
+ });
118
185
  if (answer) {
119
- const summary = answerSummary(answer);
120
- lines.push(` ${DIM}${truncate(summary, cols - 10)}${RESET}`);
186
+ const summary = singleLine(answerSummary(answer));
187
+ const summaryMax = Math.max(10, cols - 10);
188
+ rowsBuf.push({
189
+ line: ` ${DIM}${truncate(summary, summaryMax)}${RESET}`,
190
+ questionIndex: i,
191
+ });
121
192
  }
122
193
  }
123
- lines.push('');
194
+ // Reserve space for header (4 already pushed) + footer (3) + scroll hints (2).
195
+ const reserved = 4 + 3 + 2;
196
+ const available = Math.max(1, rows - reserved);
197
+ let scroll = state.scrollOffset || 0;
198
+ // Find first row matching currentIndex; ensure it's in [scroll, scroll+available).
199
+ const focusRow = rowsBuf.findIndex((r) => r.questionIndex === state.currentIndex);
200
+ if (focusRow >= 0) {
201
+ if (focusRow < scroll)
202
+ scroll = focusRow;
203
+ if (focusRow >= scroll + available)
204
+ scroll = focusRow - available + 1;
205
+ }
206
+ scroll = Math.max(0, Math.min(scroll, Math.max(0, rowsBuf.length - available)));
207
+ state.scrollOffset = scroll;
208
+ if (scroll > 0) {
209
+ lines.push(` ${DIM}↑ ${scroll} more above${RESET}`);
210
+ }
211
+ else {
212
+ lines.push('');
213
+ }
214
+ const end = Math.min(rowsBuf.length, scroll + available);
215
+ for (let i = scroll; i < end; i++)
216
+ lines.push(rowsBuf[i].line);
217
+ if (end < rowsBuf.length) {
218
+ lines.push(` ${DIM}↓ ${rowsBuf.length - end} more below${RESET}`);
219
+ }
220
+ else {
221
+ lines.push('');
222
+ }
124
223
  lines.push(` ${DIM}${hline(Math.min(cols - 4, 60))}${RESET}`);
125
224
  lines.push(` ${DIM}enter${RESET} review ${DIM}j/k${RESET} navigate ${DIM}q${RESET} finish`);
126
225
  while (lines.length < rows)
127
226
  lines.push('');
128
- return lines;
227
+ return lines.slice(0, rows);
129
228
  }
130
229
  export function renderItemReview(state) {
131
230
  const { cols, rows } = getTerminalSize();
@@ -141,11 +240,11 @@ export function renderItemReview(state) {
141
240
  lines.push(` ${DIM}${hline(maxW)}${RESET}`);
142
241
  lines.push('');
143
242
  // Question / Statement
144
- const headline = q.type === 'validation' ? q.statement : q.question;
243
+ const headline = sanitize(q.type === 'validation' ? q.statement : q.question);
145
244
  for (const line of wrap(headline, maxW)) {
146
245
  lines.push(` ${BOLD}${line}${RESET}`);
147
246
  }
148
- for (const line of wrap(q.rationale, maxW)) {
247
+ for (const line of wrap(sanitize(q.rationale), maxW)) {
149
248
  lines.push(` ${ITALIC}${GRAY}${line}${RESET}`);
150
249
  }
151
250
  lines.push('');
@@ -197,6 +296,12 @@ export function renderItemReview(state) {
197
296
  `${DIM}q${RESET} overview`,
198
297
  ];
199
298
  lines.push(` ${footerParts.join(' ')}`);
299
+ // If the headline + visual + actions overflowed the viewport, the footer
300
+ // would otherwise scroll off the bottom. Clip to `rows` so flush() never
301
+ // writes more rows than the terminal has.
302
+ if (lines.length > rows) {
303
+ return [...lines.slice(0, rows - 1), lines[lines.length - 1]];
304
+ }
200
305
  return lines;
201
306
  }
202
307
  function renderActions(q, selectedAction, existing) {
@@ -218,12 +323,15 @@ function renderActions(q, selectedAction, existing) {
218
323
  else if (q.type === 'choice') {
219
324
  for (let i = 0; i < q.options.length; i++) {
220
325
  const cursor = i === selectedAction ? `${CYAN}▸${RESET}` : ' ';
221
- const keyBadge = `${DIM}[${i + 1}]${RESET}`;
222
- lines.push(` ${cursor} ${keyBadge} ${q.options[i]}`);
326
+ // Numeric shortcut only for 1..9 — past that, the digit '1' would fire
327
+ // before the user can type the second digit, so we use a blank pad.
328
+ const keyBadge = i < 9 ? `${DIM}[${i + 1}]${RESET}` : `${DIM} ${RESET}`;
329
+ lines.push(` ${cursor} ${keyBadge} ${sanitize(q.options[i])}`);
223
330
  }
224
331
  const otherIdx = q.options.length;
225
332
  const cursor = otherIdx === selectedAction ? `${CYAN}▸${RESET}` : ' ';
226
- lines.push(` ${cursor} ${DIM}[${otherIdx + 1}]${RESET} ${ITALIC}Other (custom)${RESET}`);
333
+ const otherBadge = otherIdx < 9 ? `${DIM}[${otherIdx + 1}]${RESET}` : `${DIM} ${RESET}`;
334
+ lines.push(` ${cursor} ${otherBadge} ${ITALIC}Other (custom)${RESET}`);
227
335
  }
228
336
  else {
229
337
  lines.push(` ${DIM}[r]${RESET} Enter response`);
@@ -236,44 +344,58 @@ function renderActions(q, selectedAction, existing) {
236
344
  }
237
345
  export function renderFinal(state) {
238
346
  const { cols, rows } = getTerminalSize();
239
- const lines = [];
347
+ const header = [];
348
+ const footer = [];
240
349
  const maxW = Math.min(cols - 4, 60);
241
350
  const total = state.questions.length;
242
351
  const answered = state.answers.size;
243
- lines.push('');
244
- lines.push(` ${BOLD}${CYAN} Summary ${RESET}`);
245
- lines.push(` ${DIM}${hline(maxW)}${RESET}`);
246
- lines.push('');
247
- lines.push(` ${answered}/${total} questions answered`);
248
- lines.push('');
352
+ header.push('');
353
+ header.push(` ${BOLD}${CYAN} Summary ${RESET}`);
354
+ header.push(` ${DIM}${hline(maxW)}${RESET}`);
355
+ header.push('');
356
+ header.push(` ${answered}/${total} questions answered`);
357
+ header.push('');
358
+ footer.push('');
359
+ footer.push(` ${DIM}${hline(maxW)}${RESET}`);
360
+ if (answered < total) {
361
+ footer.push(` ${YELLOW}${total - answered} unanswered — press p to go back${RESET}`);
362
+ }
363
+ footer.push(` ${DIM}enter${RESET} submit ${DIM}p${RESET} go back`);
364
+ // Build per-question rows so we can clip to fit the viewport while
365
+ // keeping the header + footer always visible (the keybind hint at the
366
+ // bottom is essential — without it the user can't submit).
367
+ const questionRows = [];
249
368
  for (const q of state.questions) {
250
369
  const answer = state.answers.get(q.id);
251
370
  const icon = answer ? `${GREEN}✓${RESET}` : `${YELLOW}○${RESET}`;
252
- const label = q.type === 'validation' ? q.statement : q.question;
253
- lines.push(` ${icon} ${truncate(label, maxW - 4)}`);
371
+ const label = singleLine(q.type === 'validation' ? q.statement : q.question);
372
+ questionRows.push(` ${icon} ${truncate(label, Math.max(10, maxW - 4))}`);
254
373
  if (answer) {
255
- lines.push(` ${DIM}${truncate(answerSummary(answer), maxW - 6)}${RESET}`);
374
+ questionRows.push(` ${DIM}${truncate(singleLine(answerSummary(answer)), Math.max(10, maxW - 6))}${RESET}`);
256
375
  }
257
376
  }
258
- lines.push('');
259
- lines.push(` ${DIM}${hline(maxW)}${RESET}`);
260
- if (answered < total) {
261
- lines.push(` ${YELLOW}${total - answered} unanswered — press p to go back${RESET}`);
377
+ const available = Math.max(1, rows - header.length - footer.length - 1);
378
+ let visible = questionRows;
379
+ if (questionRows.length > available) {
380
+ visible = [
381
+ ...questionRows.slice(0, available - 1),
382
+ ` ${DIM}… ${questionRows.length - (available - 1)} more rows omitted${RESET}`,
383
+ ];
262
384
  }
263
- lines.push(` ${DIM}enter${RESET} submit ${DIM}p${RESET} go back`);
385
+ const lines = [...header, ...visible, ...footer];
264
386
  while (lines.length < rows)
265
387
  lines.push('');
266
- return lines;
388
+ return lines.slice(0, rows);
267
389
  }
268
390
  function answerSummary(a) {
269
391
  switch (a.type) {
270
392
  case 'validation':
271
393
  return a.approved
272
- ? (a.comment ? `approved: "${a.comment}"` : 'approved')
273
- : (a.comment ? `commented: "${a.comment}"` : 'commented');
394
+ ? (a.comment ? `approved: "${sanitize(a.comment)}"` : 'approved')
395
+ : (a.comment ? `commented: "${sanitize(a.comment)}"` : 'commented');
274
396
  case 'choice':
275
- return a.isCustom ? `custom: "${a.selected}"` : a.selected;
397
+ return a.isCustom ? `custom: "${sanitize(a.selected)}"` : sanitize(a.selected);
276
398
  case 'freetext':
277
- return a.response;
399
+ return sanitize(a.response);
278
400
  }
279
401
  }
@@ -41,6 +41,11 @@ export function parseKeypress(data) {
41
41
  const ch = String.fromCharCode(str.charCodeAt(0) + 64).toLowerCase();
42
42
  return { input: ch, key };
43
43
  }
44
+ // Multi-byte chunks (paste, multi-byte UTF-8, unknown escape sequences)
45
+ // are returned as-is in `input`; the input-mode handler is responsible for
46
+ // sanitising them before appending to its buffer. Top-level handlers
47
+ // ignore strings of length > 1, which is the desired behaviour for
48
+ // accidentally pasted text in overview/item-review.
44
49
  return { input: str, key };
45
50
  }
46
51
  export function setupTerminal() {
package/dist/tui/tmux.js CHANGED
@@ -32,12 +32,30 @@ export async function dispatchToTmuxPane(file, opts) {
32
32
  const dir = mkdtempSync(join(tmpdir(), 'hl-'));
33
33
  const resultPath = join(dir, 'result.json');
34
34
  const cmd = buildChildCmd(file, resultPath, opts);
35
- execFileSync('tmux', ['split-window', '-h', '-d', cmd], { stdio: 'ignore' });
36
- await new Promise((resolve) => {
35
+ // Capture the spawned pane id so we can detect if the user closes it
36
+ // without finishing otherwise the parent would poll forever.
37
+ const paneId = execFileSync('tmux', ['split-window', '-P', '-F', '#{pane_id}', '-h', '-d', cmd], { encoding: 'utf8' }).trim();
38
+ await new Promise((resolve, reject) => {
37
39
  const poll = setInterval(() => {
38
40
  if (existsSync(resultPath)) {
39
41
  clearInterval(poll);
40
42
  resolve();
43
+ return;
44
+ }
45
+ // Check the pane is still alive. If it's gone and there's still no
46
+ // result file, the child died (closed pane, crash, etc).
47
+ try {
48
+ const panes = execFileSync('tmux', ['list-panes', '-a', '-F', '#{pane_id}'], {
49
+ encoding: 'utf8',
50
+ });
51
+ if (!panes.split('\n').map((s) => s.trim()).includes(paneId)) {
52
+ clearInterval(poll);
53
+ reject(new Error(`tmux pane ${paneId} closed before writing a result`));
54
+ }
55
+ }
56
+ catch (err) {
57
+ clearInterval(poll);
58
+ reject(new Error(`tmux list-panes failed: ${err instanceof Error ? err.message : String(err)}`));
41
59
  }
42
60
  }, 150);
43
61
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/humanloop",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Human-in-the-loop decision TUI — agents write questions, humans answer them",
5
5
  "type": "module",
6
6
  "bin": {