@crouton-kit/humanloop 0.3.33 → 0.3.35

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.
Files changed (46) hide show
  1. package/dist/api.d.ts +4 -8
  2. package/dist/api.js +6 -29
  3. package/dist/browser/server.d.ts +3 -3
  4. package/dist/browser/server.js +22 -25
  5. package/dist/cli.js +131 -1114
  6. package/dist/editor/feedback.d.ts +9 -0
  7. package/dist/editor/feedback.js +23 -0
  8. package/dist/editor/review.d.ts +12 -30
  9. package/dist/editor/review.js +109 -119
  10. package/dist/inbox/claim.d.ts +23 -0
  11. package/dist/inbox/claim.js +67 -0
  12. package/dist/inbox/completion.d.ts +4 -0
  13. package/dist/inbox/completion.js +107 -0
  14. package/dist/inbox/controller.d.ts +74 -0
  15. package/dist/inbox/controller.js +457 -0
  16. package/dist/inbox/convention.d.ts +15 -10
  17. package/dist/inbox/convention.js +149 -79
  18. package/dist/inbox/deck-adapter.d.ts +27 -0
  19. package/dist/inbox/deck-adapter.js +57 -0
  20. package/dist/inbox/deck-schema.d.ts +18 -14
  21. package/dist/inbox/deck-schema.js +59 -117
  22. package/dist/inbox/layout.d.ts +8 -0
  23. package/dist/inbox/layout.js +9 -0
  24. package/dist/inbox/registry.d.ts +29 -0
  25. package/dist/inbox/registry.js +97 -0
  26. package/dist/inbox/review-adapter.d.ts +24 -0
  27. package/dist/inbox/review-adapter.js +57 -0
  28. package/dist/inbox/scan.d.ts +3 -2
  29. package/dist/inbox/scan.js +71 -43
  30. package/dist/inbox/tickets.d.ts +63 -0
  31. package/dist/inbox/tickets.js +247 -0
  32. package/dist/inbox/tui.d.ts +3 -6
  33. package/dist/inbox/tui.js +24 -112
  34. package/dist/index.d.ts +12 -3
  35. package/dist/index.js +8 -2
  36. package/dist/render/termrender.d.ts +5 -0
  37. package/dist/render/termrender.js +63 -4
  38. package/dist/summary.d.ts +2 -3
  39. package/dist/summary.js +2 -3
  40. package/dist/surfaces/inbox-popup.d.ts +2 -0
  41. package/dist/surfaces/inbox-popup.js +32 -0
  42. package/dist/tui/app.js +13 -0
  43. package/dist/tui/tmux.d.ts +30 -7
  44. package/dist/tui/tmux.js +190 -66
  45. package/dist/types.d.ts +68 -7
  46. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -1,1146 +1,163 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
- import { writeFileSync, mkdirSync, mkdtempSync, existsSync, readFileSync, appendFileSync, statSync, } from 'node:fs';
4
- import { readdirSync } from 'node:fs';
5
- import { tmpdir } from 'node:os';
6
- import { resolve, join, basename } from 'node:path';
7
- import { execFileSync } from 'node:child_process';
8
- import { launchReview, reviewVimscript } from './editor/review.js';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { resolve } from 'node:path';
6
+ import { validateDeck } from './inbox/deck-schema.js';
7
+ import { managedInboxRoot, registerInboxRoot, registeredInboxRoot, unregisterInboxRoot, listInboxRoots } from './inbox/registry.js';
8
+ import { submitDeck, submitReview } from './inbox/tickets.js';
9
+ import { scanInbox } from './inbox/scan.js';
10
+ import { openInboxPopup } from './surfaces/inbox-popup.js';
11
+ import { installInboxBinding, inspectInboxBinding, toggleInboxPopup, unbindInboxBinding } from './tui/tmux.js';
12
+ import { ask } from './api.js';
13
+ import { launchReview } from './editor/review.js';
9
14
  import { writeFeedbackResult } from './editor/feedback.js';
10
- import { validateDeck, resolveDeckBodyPaths } from './inbox/deck-schema.js';
11
- import { ask, inbox } from './api.js';
12
15
  import { display } from './surfaces/display.js';
13
16
  import { renderMarkdown, checkMarkdown } from './render/termrender.js';
14
- import { scanInbox } from './inbox/scan.js';
15
- import { deckPath, atomicWriteJson, readJson, responsePath, stampCanvasNode, } from './inbox/convention.js';
16
- import { buildSummary } from './summary.js';
17
- import { INTERACTION_KINDS } from './types.js';
18
- // ── Version ───────────────────────────────────────────────────────────────────
19
- const HL_VERSION = '0.2.1';
20
- function emitError(err, exitCode = 1) {
21
- process.stdout.write(JSON.stringify(err) + '\n');
22
- process.exit(exitCode);
23
- }
24
- function emitStderrError(err, exitCode = 1) {
25
- process.stderr.write(JSON.stringify(err) + '\n');
26
- process.exit(exitCode);
27
- }
28
- // ── stdin reader ──────────────────────────────────────────────────────────────
29
- function readStdin() {
30
- let content = '';
31
- const stdinResult = { ok: false, value: '' };
32
- try {
33
- const _io = {};
34
- void _io;
35
- stdinResult.value = readFileSync('/dev/stdin', 'utf8');
36
- stdinResult.ok = true;
37
- }
38
- catch (stdinErr) {
39
- process.stderr.write(`[hl] stdin read error: ${stdinErr instanceof Error ? stdinErr.message : String(stdinErr)}\n`);
40
- }
41
- content = stdinResult.value;
42
- return content;
43
- }
44
- function parseStdinJson() {
45
- const raw = readStdin().trim();
46
- if (!raw) {
47
- emitStderrError({
48
- error: 'bad_stdin_json',
49
- message: 'No input on stdin. Expected a JSON object.',
50
- next: "Pipe a JSON object to stdin, e.g.: echo '{\"deck\":{...}}' | hl deck ask",
51
- });
52
- }
53
- let parsed;
54
- const parseResult = { ok: false };
55
- try {
56
- const _p = {};
57
- void _p;
58
- parseResult.value = JSON.parse(raw);
59
- parseResult.ok = true;
60
- }
61
- catch (parseErr) {
62
- emitStderrError({
63
- error: 'bad_stdin_json',
64
- message: `stdin is not valid JSON: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`,
65
- received: raw.slice(0, 200),
66
- next: 'Pipe a valid JSON object to stdin.',
67
- });
68
- }
69
- if (parseResult.ok)
70
- parsed = parseResult.value;
71
- return parsed;
72
- }
73
- function jobLogPath(dir) {
74
- return join(dir, 'job.log');
75
- }
76
- function appendJobLog(dir, entry) {
77
- const line = { ts: new Date().toISOString(), ...entry };
78
- const serialized = JSON.stringify(line) + '\n';
79
- const logResult = { wrote: false };
80
- try {
81
- const _l = {};
82
- void _l;
83
- appendFileSync(jobLogPath(dir), serialized);
84
- logResult.wrote = true;
85
- }
86
- catch (writeErr) {
87
- void Object.assign(logResult, { error: String(writeErr) }); // best-effort; never throws
88
- }
89
- }
90
- // ── job dir resolution ────────────────────────────────────────────────────────
91
- function resolveJobDir(jobId) {
92
- if (jobId.startsWith('/'))
93
- return jobId;
94
- const td = tmpdir();
95
- const candidate = join(td, jobId);
96
- if (existsSync(candidate))
97
- return candidate;
98
- let entries = [];
99
- const scanResult = { entries: [] };
100
- try {
101
- const _s = {};
102
- void _s;
103
- scanResult.entries = readdirSync(td);
104
- }
105
- catch (readdirErr) {
106
- void Object.assign(scanResult, { error: String(readdirErr) }); // opportunistic scan
107
- return candidate;
108
- }
109
- entries = scanResult.entries;
110
- for (const e of entries) {
111
- if (e === jobId || basename(e) === jobId) {
112
- const full = join(td, e);
113
- if (existsSync(join(full, 'deck.json')))
114
- return full;
115
- }
116
- }
117
- return candidate;
17
+ function input() {
18
+ const raw = readFileSync('/dev/stdin', 'utf8').trim();
19
+ if (!raw)
20
+ throw new Error('expected one JSON object on stdin');
21
+ return JSON.parse(raw);
118
22
  }
119
- function detectJobKind(dir) {
120
- if (existsSync(join(dir, 'deck.json')))
121
- return 'deck';
122
- if (existsSync(join(dir, 'feedback.json')) || existsSync(join(dir, 'review.vim')))
123
- return 'review';
124
- return 'inbox';
125
- }
126
- function tryParseJson(text) {
127
- try {
128
- const _j = {};
129
- void _j;
130
- return JSON.parse(text);
131
- }
132
- catch (parseErr) {
133
- void String(parseErr);
134
- return null;
135
- }
136
- }
137
- function readLogLines(logPath) {
138
- try {
139
- const _r = {};
140
- void _r;
141
- return readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean);
142
- }
143
- catch (readErr) {
144
- void String(readErr);
145
- return [];
146
- }
23
+ function objectInput() {
24
+ const value = input();
25
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
26
+ throw new Error('expected a JSON object on stdin');
27
+ return value;
147
28
  }
148
- function detectJobState(dir) {
149
- if (existsSync(join(dir, 'response.json')))
150
- return 'done';
151
- if (existsSync(join(dir, 'feedback.json'))) {
152
- const fb = tryParseJson(readFileSync(join(dir, 'feedback.json'), 'utf8'));
153
- if (fb && fb.submitted)
154
- return 'done';
155
- }
156
- const logPath = jobLogPath(dir);
157
- if (existsSync(logPath)) {
158
- const lines = readLogLines(logPath);
159
- for (let i = lines.length - 1; i >= 0; i--) {
160
- const entry = tryParseJson(lines[i]);
161
- if (!entry)
162
- continue;
163
- if (entry.event === 'job_failed')
164
- return 'failed';
165
- if (entry.event === 'job_canceled')
166
- return 'canceled';
167
- if (entry.event === 'job_finished')
168
- return 'done';
169
- }
170
- }
171
- return 'live';
29
+ function emit(value) { process.stdout.write(`${JSON.stringify(value)}\n`); }
30
+ function fail(error) {
31
+ const message = error instanceof Error ? error.message : String(error);
32
+ const family = process.argv[2] ?? '';
33
+ const help = new Set(['inbox', 'deck', 'review', 'view', 'doc']).has(family) ? `hl ${family} --help` : 'hl inbox --help';
34
+ emit({ error: 'bad_input', message, next: `Run ${help} for usage.` });
35
+ process.exit(1);
172
36
  }
173
- function lastLogEvent(dir) {
174
- const logPath = jobLogPath(dir);
175
- if (!existsSync(logPath))
176
- return null;
177
- const lines = readLogLines(logPath);
178
- for (let i = lines.length - 1; i >= 0; i--) {
179
- const entry = tryParseJson(lines[i]);
180
- if (entry)
181
- return entry;
182
- }
183
- return null;
37
+ /** Explicit --root values filter the scan; no --root (an empty array from commander) falls through to the registered-roots fallback in scanInbox/the controller, so it must be undefined, not []. */
38
+ function roots(values) { return values && values.length > 0 ? values.map((root) => resolve(root)) : undefined; }
39
+ /** Resolve an explicit --root to its existing registration; an unregistered root is an error, never a silent humanloop-owned registration. */
40
+ function requireRegisteredRoot(root) {
41
+ const abs = resolve(root);
42
+ const registration = registeredInboxRoot(abs);
43
+ if (registration === null)
44
+ throw new Error(`root ${abs} is not registered; run \`hl inbox roots register --root ${abs} --owner <owner>\` first`);
45
+ return registration;
184
46
  }
185
- // ── tmux child-mode env var (internal, not a CLI flag) ───────────────────────
186
- // When the parent dispatches to a tmux pane, it sets HL_WRITE_TO=<path> so the
187
- // child writes its result there instead of stdout. Implementation detail only.
188
- const INTERNAL_WRITE_TO = process.env['HL_WRITE_TO'];
189
- // ── Schemas ───────────────────────────────────────────────────────────────────
190
- const REQUEST_SCHEMA = {
191
- $schema: 'https://json-schema.org/draft/2020-12/schema',
192
- description: 'Input schema for hl deck ask (v2)',
193
- type: 'object',
194
- required: ['interactions'],
195
- properties: {
196
- title: { type: 'string', description: 'Optional deck title shown in the TUI header' },
197
- interactions: {
198
- type: 'array',
199
- minItems: 1,
200
- items: {
201
- type: 'object',
202
- required: ['id', 'title', 'options'],
203
- properties: {
204
- id: { type: 'string', description: 'Unique identifier.' },
205
- title: { type: 'string', description: 'Noun-phrase topic (≤4 words).' },
206
- subtitle: { type: 'string' },
207
- body: { type: 'string', description: 'ELI12 markdown body.' },
208
- bodyPath: { type: 'string', description: 'Path to a markdown file used in place of body.' },
209
- options: {
210
- type: 'array',
211
- items: {
212
- type: 'object',
213
- required: ['id', 'label'],
214
- properties: {
215
- id: { type: 'string' },
216
- label: { type: 'string' },
217
- description: { type: 'string' },
218
- },
219
- },
220
- },
221
- allowFreetext: { type: 'boolean' },
222
- freetextLabel: { type: 'string' },
223
- kind: { type: 'string', enum: [...INTERACTION_KINDS] },
224
- },
225
- },
226
- },
227
- },
228
- };
229
- const RESPONSE_SCHEMA = {
230
- $schema: 'https://json-schema.org/draft/2020-12/schema',
231
- $id: 'humanloop.response/v2',
232
- description: 'Resolution envelope emitted by hl deck ask / returned by ask().',
233
- type: 'object',
234
- required: ['summary', 'responsePath', 'schema', 'responses', 'completedAt'],
235
- properties: {
236
- summary: { type: 'string' },
237
- responsePath: { type: 'string' },
238
- schema: { const: 'humanloop.response/v2' },
239
- responses: {
240
- type: 'array',
241
- items: {
242
- type: 'object',
243
- required: ['id'],
244
- properties: {
245
- id: { type: 'string' },
246
- selectedOptionId: { type: 'string' },
247
- selectedOptionIds: { type: 'array', items: { type: 'string' } },
248
- freetext: { type: 'string' },
249
- optionComments: {
250
- type: 'object',
251
- description: 'Multi-select per-option comments, keyed by option id.',
252
- additionalProperties: { type: 'string' },
253
- },
254
- },
255
- },
256
- },
257
- completedAt: { type: 'string' },
258
- },
259
- };
260
- const FEEDBACK_SCHEMA = {
261
- $schema: 'https://json-schema.org/draft/2020-12/schema',
262
- description: 'FeedbackResult written by hl review open / launchReview().',
263
- type: 'object',
264
- required: ['file', 'submitted', 'approved', 'comments', 'savedAt'],
265
- properties: {
266
- file: { type: 'string', description: 'Absolute path to the reviewed markdown file.' },
267
- submitted: { type: 'boolean' },
268
- approved: { type: 'boolean', description: 'True when submitted with zero comments.' },
269
- comments: {
270
- type: 'array',
271
- items: {
272
- type: 'object',
273
- required: ['id', 'line', 'endLine', 'lineText', 'comment', 'createdAt'],
274
- properties: {
275
- id: { type: 'string' },
276
- line: { type: 'integer', description: '1-based source line (start).' },
277
- endLine: { type: 'integer', description: '1-based source line (end).' },
278
- quote: { type: 'string' },
279
- colStart: { type: 'integer' },
280
- colEnd: { type: 'integer' },
281
- lineText: { type: 'string' },
282
- comment: { type: 'string' },
283
- createdAt: { type: 'string' },
284
- },
285
- },
286
- },
287
- submittedAt: { type: 'string' },
288
- savedAt: { type: 'string' },
289
- },
290
- };
291
- // ── Commander tree ────────────────────────────────────────────────────────────
292
47
  const program = new Command();
293
- program
294
- .name('hl')
295
- .description(`hl ${HL_VERSION} human-in-the-loop TUI bridge for agents\n` +
296
- '\n' +
297
- 'I/O contract: every leaf reads ONE JSON object from stdin; writes ONE JSON\n' +
298
- 'object (or JSONL for streams) to stdout; exits 0 on success, non-zero on\n' +
299
- 'error. Errors are always a JSON object on stdout:\n' +
300
- ' { error: <code>, message, next } — codes: bad_stdin_json | bad_input |\n' +
301
- ' deck_invalid | file_not_found | editor_not_found | job_not_found |\n' +
302
- ' not_ready | not_in_tmux | internal\n' +
303
- '\n' +
304
- 'Concepts:\n' +
305
- ' deck — structured set of interactions (questions) for the human\n' +
306
- ' review — freeform markdown document review with anchored comments\n' +
307
- ' view — passive live render of a file in a tmux pane\n' +
308
- ' doc — render or validate directive-flavored markdown to stdout\n' +
309
- ' inbox — list/resolve all pending interactions across root dirs\n' +
310
- ' job — a running or completed kickoff (deck ask / review / inbox)\n' +
311
- ' schema — JSON Schema for deck, resolution, or feedback payloads\n' +
312
- '\n' +
313
- 'Subtrees:\n' +
314
- ' hl deck — write questions, get answers | use when: material decisions\n' +
315
- ' hl review — markdown doc review | use when: doc feedback needed\n' +
316
- ' hl view — live render in pane | use when: displaying a file\n' +
317
- ' hl doc — render/validate to stdout | use when: piping rendered markdown\n' +
318
- ' hl inbox — browse pending interactions | use when: clearing a backlog\n' +
319
- ' hl job — inspect/wait/cancel running jobs | use when: polling job output\n' +
320
- ' hl schema — print JSON Schemas | use when: validating inputs\n' +
321
- '\n' +
322
- 'Globals: -h / --help on any node.\n')
323
- .helpOption('-h, --help', 'Show help')
324
- .addHelpCommand(false);
325
- // ── deck ──────────────────────────────────────────────────────────────────────
326
- const deckCmd = program.command('deck').description('Write questions, get answers from the human.\n' +
327
- '\n' +
328
- 'Children:\n' +
329
- ' hl deck ask — spawn the decisions TUI, return a job handle | use when: posing material decisions\n' +
330
- ' hl deck update — replace the deck of a LIVE ask job in place | use when: the questions changed after ask\n' +
331
- ' hl deck validate — preflight a deck object, no side effects | use when: checking a deck before ask\n' +
332
- '\n' +
333
- 'A `deck update` rewrites the live job\'s deck.json; the TUI pane the\n' +
334
- 'human is looking at reloads it automatically within ~1s (answers whose\n' +
335
- 'interaction ids still exist are kept). Read this leaf\'s -h before calling\n' +
336
- 'it — it mutates a session a human is actively in.');
337
- deckCmd
338
- .command('ask')
339
- .description('Kickoff: spawn the decisions TUI and return immediately.\n' +
340
- '\n' +
341
- 'stdin { deck: object (required), dir?: string|null,\n' +
342
- ' sessionId?: string|null, visuals?: bool=true, tmux?: bool=true }\n' +
343
- 'stdout { job_id: string, dir: string (absolute), follow_up: string }\n' +
344
- '\n' +
345
- 'Effects: writes <dir>/deck.json, <dir>/progress.json (live),\n' +
346
- ' <dir>/response.json (on finish), <dir>/job.log (JSONL).\n' +
347
- ' Spawns TUI detached in a tmux pane when tmux=true and $TMUX set.\n' +
348
- ' While the job is live the TUI watches <dir>/deck.json: a later\n' +
349
- ' `hl deck update` rewrites it and the pane reloads automatically.\n')
350
- .helpOption('-h, --help', 'Show help')
351
- .action(async () => {
352
- const input = parseStdinJson();
353
- if (!input.deck || typeof input.deck !== 'object') {
354
- emitError({
355
- error: 'bad_input',
356
- message: 'deck is required and must be an object',
357
- field: 'deck',
358
- next: "Run: echo '{\"kind\":\"deck\"}' | hl schema show",
359
- });
360
- }
361
- let deck;
362
- try {
363
- const _v = {};
364
- void _v;
365
- deck = validateDeck(input.deck);
366
- }
367
- catch (validationErr) {
368
- emitError({
369
- error: 'deck_invalid',
370
- message: `deck validation failed: ${validationErr instanceof Error ? validationErr.message : String(validationErr)}`,
371
- received: input.deck,
372
- next: "Fix the deck object. Run: echo '{\"deck\":{...}}' | hl deck validate",
373
- });
374
- }
375
- const dir = input.dir ? resolve(input.dir) : mkdtempSync(join(tmpdir(), 'hl-ix-'));
376
- mkdirSync(dir, { recursive: true });
377
- // Canonical bodyPath → body normalization boundary (deck-schema.ts) —
378
- // resolved before deck.json is ever written, so terminal render, the
379
- // live-reload poller, and the browser server all just see `body`.
380
- try {
381
- deck = resolveDeckBodyPaths(deck, dir);
382
- }
383
- catch (bodyErr) {
384
- emitError({
385
- error: 'deck_invalid',
386
- message: bodyErr instanceof Error ? bodyErr.message : String(bodyErr),
387
- next: 'Fix bodyPath (must point at a real file inside the interaction dir) and retry.',
388
- });
389
- }
390
- stampCanvasNode(deck);
391
- atomicWriteJson(deckPath(dir), deck);
392
- const jobId = basename(dir);
393
- const visuals = input.visuals !== false;
394
- const useTmux = input.tmux !== false;
395
- let sessionId;
396
- if (visuals) {
397
- if (input.sessionId && typeof input.sessionId === 'string') {
398
- sessionId = input.sessionId;
399
- }
400
- else {
401
- // dynamic import to avoid pulling heavy dep into parse path
402
- const { findRecentSessionId } = await import('./conversation/reader.js');
403
- sessionId = findRecentSessionId(process.cwd()) || findRecentSessionId() || undefined;
404
- }
405
- }
406
- appendJobLog(dir, { level: 'info', event: 'job_started', message: 'deck ask job started', data: { jobId } });
407
- if (INTERNAL_WRITE_TO) {
408
- // Child mode: run ask() in-process, write result to INTERNAL_WRITE_TO
409
- try {
410
- const result = await ask(deck, { dir, sessionId });
411
- appendJobLog(dir, { level: 'info', event: 'job_finished', message: 'deck resolved', data: { jobId } });
412
- writeFileSync(INTERNAL_WRITE_TO, JSON.stringify(result) + '\n');
413
- process.exit(0);
414
- }
415
- catch (askErr) {
416
- appendJobLog(dir, {
417
- level: 'error', event: 'job_failed',
418
- message: askErr instanceof Error ? askErr.message : String(askErr),
419
- });
420
- process.exit(1);
421
- }
422
- }
423
- if (process.env['TMUX'] && useTmux) {
424
- const scriptPath = process.argv[1];
425
- if (!scriptPath) {
426
- emitError({ error: 'internal', message: 'Cannot determine hl script path', next: 'Report this as a bug.' });
427
- }
428
- const sq = (s) => /^[a-zA-Z0-9_\-./:@%+=]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
429
- const childInput = JSON.stringify({ deck, dir, sessionId, visuals, tmux: false });
430
- const cmd = `echo ${sq(childInput)} | ${sq(process.execPath)} ${sq(scriptPath)} deck ask`;
431
- try {
432
- execFileSync('tmux', ['split-window', '-d', '-h', cmd], { stdio: 'ignore' });
433
- }
434
- catch (spawnErr) {
435
- const msg = spawnErr instanceof Error ? spawnErr.message : String(spawnErr);
436
- appendJobLog(dir, { level: 'error', event: 'job_failed', message: `tmux spawn failed: ${msg}` });
437
- emitError({
438
- error: 'internal',
439
- message: `tmux spawn failed: ${msg}`,
440
- next: 'Check that $TMUX is set. Or pass tmux:false.',
441
- });
442
- }
443
- process.stdout.write(JSON.stringify({
444
- job_id: jobId,
445
- dir,
446
- 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.`,
447
- }) + '\n');
448
- process.exit(0);
449
- }
450
- // Non-tmux path: ask() runs in-process (will block or fail without TTY — degraded).
451
- appendJobLog(dir, { level: 'warn', event: 'job_started', message: 'no tmux; ask() runs in-process' });
452
- try {
453
- const result = await ask(deck, { dir, sessionId });
454
- appendJobLog(dir, { level: 'info', event: 'job_finished', message: 'deck resolved', data: { jobId } });
455
- writeFileSync(join(dir, 'response.json'), JSON.stringify({ responses: result.responses, completedAt: result.completedAt }, null, 2));
456
- process.stdout.write(JSON.stringify({
457
- job_id: jobId,
458
- dir,
459
- follow_up: `Call hl job result with stdin {"job_id":"${jobId}","wait":true} to retrieve the result.`,
460
- _note: 'Non-tmux path: ask() blocked synchronously. Result is already available.',
461
- }) + '\n');
462
- process.exit(0);
463
- }
464
- catch (askErr) {
465
- const msg = askErr instanceof Error ? askErr.message : String(askErr);
466
- appendJobLog(dir, { level: 'error', event: 'job_failed', message: msg });
467
- emitError({
468
- error: 'internal',
469
- message: `ask() failed: ${msg}`,
470
- next: 'Set tmux:true (or run inside tmux) so the TUI can open an interactive pane.',
471
- });
472
- }
48
+ program.name('hl').description('Humanloop durable inbox and review surface.').helpOption('-h, --help');
49
+ const inbox = program.command('inbox').description('Open, inspect, and configure the centralized human inbox.');
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
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
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));
473
54
  });
474
- deckCmd
475
- .command('update')
476
- .description('Replace the deck of a LIVE ask job; the human\'s TUI pane reloads.\n' +
477
- '\n' +
478
- 'stdin { job_id: string (required), deck: object (required) }\n' +
479
- 'stdout { ok: true, job_id: string, interactions: int, follow_up: string }\n' +
480
- '\n' +
481
- 'The TUI watches deck.json and reloads within ~1s of this write. Answers\n' +
482
- 'whose interaction id still exists in the new deck are preserved; new or\n' +
483
- 'id-changed interactions appear unanswered. In-flight unsubmitted input\n' +
484
- '(a comment being typed) is discarded on reload.\n' +
485
- '\n' +
486
- 'Errors: job_not_found (no such job_id) | job_not_live (already\n' +
487
- 'done/failed/canceled — nothing to reload) | deck_invalid (deck rejected;\n' +
488
- 'the old deck stays in place, run hl deck validate first).\n' +
489
- '\n' +
490
- 'Effects: atomically rewrites <dir>/deck.json; appends a deck_updated\n' +
491
- 'event to <dir>/job.log. No effect on response.json/progress.json.\n')
492
- .helpOption('-h, --help', 'Show help')
493
- .action(() => {
494
- const input = parseStdinJson();
495
- if (!input.job_id || typeof input.job_id !== 'string') {
496
- emitError({ error: 'bad_input', message: 'job_id is required', field: 'job_id', next: 'Provide: {"job_id": "<id>", "deck": {...}}' });
497
- }
498
- if (!input.deck || typeof input.deck !== 'object') {
499
- emitError({ error: 'bad_input', message: 'deck is required and must be an object', field: 'deck', next: "Run: echo '{\"kind\":\"deck\"}' | hl schema show" });
500
- }
501
- const dir = resolveJobDir(input.job_id);
502
- if (!existsSync(dir) || !existsSync(deckPath(dir))) {
503
- emitError({ error: 'job_not_found', message: `Job not found: ${input.job_id}`, next: 'Check the job_id returned by hl deck ask.' });
504
- }
505
- const state = detectJobState(dir);
506
- if (state !== 'live') {
507
- emitError({
508
- error: 'job_not_live',
509
- message: `Job is ${state}; its deck can no longer be reloaded.`,
510
- received: state,
511
- next: 'The human already finished. Start a fresh deck with hl deck ask.',
512
- });
513
- }
514
- let deck;
515
- try {
516
- const _v = {};
517
- void _v;
518
- deck = validateDeck(input.deck);
519
- }
520
- catch (validationErr) {
521
- emitError({
522
- error: 'deck_invalid',
523
- message: `deck validation failed: ${validationErr instanceof Error ? validationErr.message : String(validationErr)}`,
524
- received: input.deck,
525
- next: "The live deck is unchanged. Fix the deck, then: echo '{\"deck\":{...}}' | hl deck validate",
526
- });
527
- }
528
- // Same canonical bodyPath → body normalization boundary as `deck ask`.
529
- try {
530
- deck = resolveDeckBodyPaths(deck, dir);
531
- }
532
- catch (bodyErr) {
533
- emitError({
534
- error: 'deck_invalid',
535
- message: bodyErr instanceof Error ? bodyErr.message : String(bodyErr),
536
- next: 'The live deck is unchanged. Fix bodyPath (must point at a real file inside the interaction dir) and retry.',
537
- });
538
- }
539
- atomicWriteJson(deckPath(dir), deck);
540
- appendJobLog(dir, {
541
- level: 'info', event: 'deck_updated',
542
- message: `deck replaced (${deck.interactions.length} interaction(s)); pane reloads on next watch tick`,
543
- data: { jobId: basename(dir), interactions: deck.interactions.length },
544
- });
545
- process.stdout.write(JSON.stringify({
546
- ok: true,
547
- job_id: basename(dir),
548
- interactions: deck.interactions.length,
549
- follow_up: `The pane reloads within ~1s. Still resolve with hl job result {"job_id":"${basename(dir)}","wait":true}.`,
550
- }) + '\n');
551
- process.exit(0);
55
+ 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
+ const result = await toggleInboxPopup({ socket: options.tmuxSocket, client: options.tmuxClient, targetPane: options.targetPane });
57
+ // Under the `run-shell -b` binding, any stdout becomes a tmux view-mode overlay on the active
58
+ // pane. The opened/closed popup is its own feedback, so stay silent on every non-failure result;
59
+ // a genuine `failed` still prints so a broken binding is not invisible.
60
+ if (!options.quiet || result === 'failed')
61
+ emit({ result, next: result === 'not_in_tmux' ? 'Run hl inbox open in a human terminal.' : undefined });
62
+ process.exit(result === 'failed' || result === 'ambiguous_client' ? 1 : 0);
552
63
  });
553
- deckCmd
554
- .command('validate')
555
- .description('Preflight deck validation no side effects.\n' +
556
- '\n' +
557
- 'stdin { deck: object }\n' +
558
- 'stdout { ok: bool, errors: [{field, message}] }\n' +
559
- 'exit 0 if ok, 1 if invalid\n')
560
- .helpOption('-h, --help', 'Show help')
561
- .action(() => {
562
- const input = parseStdinJson();
563
- if (!input.deck) {
564
- emitError({ error: 'bad_input', message: 'deck is required', field: 'deck', next: 'Provide: {"deck": {...}}' });
565
- }
566
- try {
567
- validateDeck(input.deck);
568
- process.stdout.write(JSON.stringify({ ok: true, errors: [] }) + '\n');
569
- process.exit(0);
570
- }
571
- catch (validationErr) {
572
- const errors = parseValidationErrors(validationErr);
573
- process.stdout.write(JSON.stringify({ ok: false, errors }) + '\n');
574
- process.exit(1);
575
- }
64
+ inbox.command('list').description('Print pending tickets newest first as JSON.').option('--root <path>', 'filter to a root', (value, prior) => [...prior, value], []).action((options) => emit(scanInbox(roots(options.root))));
65
+ const root = inbox.command('roots').description('Manage durable interaction roots.');
66
+ root.command('register').description('Register a root owned by a host.').requiredOption('--root <path>').requiredOption('--owner <owner>').option('--handler-command <path>').option('--handler-arg <arg>', 'repeatable direct-exec handler argument', (value, prior) => [...prior, value], []).action((options) => {
67
+ if ((options.handlerCommand === undefined) !== (options.handlerArg.length === 0))
68
+ fail('a completion handler requires both --handler-command and at least one --handler-arg');
69
+ emit(registerInboxRoot({ root: resolve(options.root), owner: options.owner, ...(options.handlerCommand === undefined ? {} : { handler: { command: options.handlerCommand, args: options.handlerArg } }) }));
576
70
  });
577
- function parseValidationErrors(e) {
578
- if (e && typeof e === 'object' && 'issues' in e) {
579
- const issues = e.issues;
580
- return issues.map((iss) => ({ field: iss.path.join('.'), message: iss.message }));
581
- }
582
- return [{ field: '', message: e instanceof Error ? e.message : String(e) }];
583
- }
584
- // ── review ────────────────────────────────────────────────────────────────────
585
- const reviewCmd = program.command('review').description('Markdown document review with anchored comments.');
586
- reviewCmd
587
- .command('open')
588
- .description('Open a read-only editor review and BLOCK until the human submits.\n' +
589
- '\n' +
590
- 'stdin { file: string (required, .md), output?: string|null,\n' +
591
- ' editor?: string|null, tmux?: bool=true }\n' +
592
- 'stdout { job_id: string, output: string (absolute),\n' +
593
- ' status: "done"|"failed"|"canceled", result?: FeedbackResult }\n' +
594
- '\n' +
595
- 'Effects: spawns nvim/vim read-only in a tmux pane when tmux=true and $TMUX\n' +
596
- ' set, then blocks until the human finishes and submits. Writes\n' +
597
- ' <dir>/review.vim, <dir>/feedback.json (on finish), <dir>/job.log.\n' +
598
- ' autosaves feedback JSON; the open pane live-reloads the source on\n' +
599
- ' disk edits. The review is open-ended (a human may take many\n' +
600
- ' minutes) — if you want to keep working, run this BACKGROUNDED; your\n' +
601
- ' harness notifies you when it returns with the result.\n')
602
- .helpOption('-h, --help', 'Show help')
603
- .action(async () => {
604
- const input = process.env.HL_REVIEW_OPEN_INPUT
605
- ? JSON.parse(process.env.HL_REVIEW_OPEN_INPUT)
606
- : parseStdinJson();
607
- if (!input.file || typeof input.file !== 'string') {
608
- emitError({ error: 'bad_input', message: 'file is required', field: 'file', next: 'Provide: {"file": "/path/to/doc.md"}' });
609
- }
610
- const absFile = resolve(input.file);
611
- if (!existsSync(absFile)) {
612
- emitError({ error: 'file_not_found', message: `File not found: ${absFile}`, field: 'file', next: 'Check the file path.' });
613
- }
614
- const output = resolve(input.output ? input.output : `${absFile}.feedback.json`);
615
- const useTmux = input.tmux !== false;
616
- // Shared job dir: the detached child reuses the parent's via input.dir;
617
- // a top-level call mints one. The review.vim is written up front (and the
618
- // job_started logged) so the job is recognizable as a live review — and so
619
- // the child sources the exact vimscript — before any pane is spawned.
620
- const jobDir = input.dir ? resolve(input.dir) : mkdtempSync(join(tmpdir(), 'hl-review-'));
621
- mkdirSync(jobDir, { recursive: true });
622
- const jobId = basename(jobDir);
623
- if (!input.dir) {
624
- writeFileSync(join(jobDir, 'review.vim'), reviewVimscript());
625
- appendJobLog(jobDir, { level: 'info', event: 'job_started', message: 'review open job started', data: { jobId, file: absFile } });
626
- }
627
- // tmux path: detach a child that owns the editor pane and return the handle
628
- // now, mirroring `deck ask`. The child re-enters this leaf with tmux:false
629
- // and the shared dir, falling through to the in-process branch below.
630
- if (process.env['TMUX'] && useTmux) {
631
- const scriptPath = process.argv[1];
632
- if (!scriptPath) {
633
- emitError({ error: 'internal', message: 'Cannot determine hl script path', next: 'Report this as a bug.' });
634
- }
635
- const sq = (s) => /^[a-zA-Z0-9_\-./:@%+=]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
636
- const childInput = JSON.stringify({ file: absFile, output, editor: input.editor ?? null, tmux: false, dir: jobDir });
637
- const cmd = `HL_REVIEW_OPEN_INPUT=${sq(childInput)} ${sq(process.execPath)} ${sq(scriptPath)} review open`;
638
- try {
639
- execFileSync('tmux', ['split-window', '-d', '-h', cmd], { stdio: 'ignore' });
640
- }
641
- catch (spawnErr) {
642
- const msg = spawnErr instanceof Error ? spawnErr.message : String(spawnErr);
643
- appendJobLog(jobDir, { level: 'error', event: 'job_failed', message: `tmux spawn failed: ${msg}` });
644
- emitError({ error: 'internal', message: `tmux spawn failed: ${msg}`, next: 'Check that $TMUX is set. Or pass tmux:false.' });
645
- }
646
- // BLOCK until the human submits (or the job ends). The review is
647
- // open-ended — a human may take many minutes — so callers that want to
648
- // keep working should background this invocation; their harness notifies
649
- // them when it returns. Poll the shared job dir for feedback.json the
650
- // same way `hl job result --wait` does.
651
- await new Promise((resolvePromise) => {
652
- const poll = setInterval(() => {
653
- const fp = join(jobDir, 'feedback.json');
654
- const result = existsSync(fp) ? tryParseJson(readFileSync(fp, 'utf8')) : null;
655
- if (result !== null && result.submitted === true) {
656
- clearInterval(poll);
657
- process.stdout.write(JSON.stringify({ job_id: jobId, output, status: 'done', result }) + '\n');
658
- process.exit(0);
659
- }
660
- const state = detectJobState(jobDir);
661
- if (state === 'done' && result !== null) {
662
- clearInterval(poll);
663
- process.stdout.write(JSON.stringify({ job_id: jobId, output, status: 'done', result }) + '\n');
664
- process.exit(0);
665
- }
666
- if (state === 'failed' || state === 'canceled') {
667
- clearInterval(poll);
668
- process.stdout.write(JSON.stringify({ job_id: jobId, output, status: state }) + '\n');
669
- process.exit(state === 'canceled' ? 0 : 1);
670
- }
671
- }, 200);
672
- void resolvePromise;
673
- });
674
- return;
675
- }
676
- // In-process path: the detached child (this pane is its TTY), or a degraded
677
- // top-level call with no tmux. launchReview blocks until the editor exits.
71
+ 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
+ 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
+ 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) => {
678
77
  try {
679
- const result = await launchReview(absFile, {
680
- output,
681
- editor: (input.editor && typeof input.editor === 'string') ? input.editor : undefined,
682
- noTmux: true,
683
- jobDir,
684
- });
685
- appendJobLog(jobDir, { level: 'info', event: 'job_finished', message: 'review finished', data: { comments: result.comments.length } });
686
- writeFeedbackResult(join(jobDir, 'feedback.json'), result);
687
- process.stdout.write(JSON.stringify({
688
- job_id: jobId,
689
- output,
690
- status: 'done',
691
- result,
692
- ...(input.dir ? {} : { _note: 'No tmux: launchReview blocked synchronously. Result is already available.' }),
693
- }) + '\n');
694
- process.exit(0);
695
- }
696
- catch (reviewErr) {
697
- const msg = reviewErr instanceof Error ? reviewErr.message : String(reviewErr);
698
- appendJobLog(jobDir, { level: 'error', event: 'job_failed', message: msg });
699
- if (msg.startsWith('Markdown file not found')) {
700
- emitError({ error: 'file_not_found', message: msg, next: 'Check the file path.' });
78
+ const body = objectInput();
79
+ const deck = validateDeck(body.deck);
80
+ if (options.inline) {
81
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
82
+ throw new Error('hl deck ask --inline requires an interactive TTY; omit --inline to submit a durable ticket');
83
+ emit(await ask(deck));
84
+ return;
701
85
  }
702
- if (msg.startsWith('Editor not found') || msg.startsWith('No editor found')) {
703
- emitError({ error: 'editor_not_found', message: msg, next: 'Install Neovim (brew install neovim) or pass editor.' });
704
- }
705
- emitError({ error: 'internal', message: msg, next: 'Check stderr for details.' });
706
- }
707
- });
708
- // ── view ──────────────────────────────────────────────────────────────────────
709
- const viewCmd = program.command('view').description('Passive live render of a file in a tmux pane.');
710
- viewCmd
711
- .command('show')
712
- .description('Render a file live in a tmux pane — passive, no result.\n' +
713
- '\n' +
714
- 'The pane always watches the file and live-updates on every save.\n' +
715
- '\n' +
716
- 'stdin { path: string (required), window?: "split"|"new"="split" }\n' +
717
- 'stdout { pane_id: string|null, reason: string|null }\n' +
718
- 'exit 0 always (not-in-tmux / renderer-unavailable is NOT an error)\n')
719
- .helpOption('-h, --help', 'Show help')
720
- .action(() => {
721
- const input = parseStdinJson();
722
- if (!input.path || typeof input.path !== 'string') {
723
- emitError({ error: 'bad_input', message: 'path is required', field: 'path', next: 'Provide: {"path": "/abs/path/file.md"}' });
724
- }
725
- const absPath = resolve(input.path);
726
- const window = input.window === 'new' ? 'new' : 'split';
727
- const res = display(absPath, { window });
728
- if (res.paneId) {
729
- process.stdout.write(JSON.stringify({ pane_id: res.paneId, reason: null }) + '\n');
730
- }
731
- else {
732
- process.stdout.write(JSON.stringify({ pane_id: null, reason: 'Not in tmux or termrender unavailable.' }) + '\n');
733
- }
734
- process.exit(0);
735
- });
736
- // ── doc ───────────────────────────────────────────────────────────────────────
737
- const docCmd = program.command('doc').description('Render or validate directive-flavored markdown to stdout.\n' +
738
- '\n' +
739
- 'Children:\n' +
740
- ' hl doc check — validate directive syntax, no output | use when: preflighting before write\n' +
741
- ' hl doc render — render markdown to ANSI/plain stdout | use when: piping rendered text to a file or consumer\n' +
742
- '\n' +
743
- 'These wrap the pinned termrender binary that humanloop manages. Consumers\n' +
744
- 'should never call `termrender` directly — go through hl/SDK so there is one\n' +
745
- 'org-wide caller.\n');
746
- docCmd
747
- .command('check')
748
- .description('Validate directive-flavored markdown without rendering.\n' +
749
- '\n' +
750
- 'stdin { source?: string, path?: string } exactly one required\n' +
751
- 'stdout { ok: bool, error?: string }\n' +
752
- 'exit 0 always (validation failures are not process errors)\n')
753
- .helpOption('-h, --help', 'Show help')
754
- .action(() => {
755
- const input = parseStdinJson();
756
- const src = resolveDocSource(input);
757
- const res = checkMarkdown(src);
758
- process.stdout.write(JSON.stringify(res) + '\n');
759
- process.exit(0);
760
- });
761
- docCmd
762
- .command('render')
763
- .description('Render directive-flavored markdown to ANSI or plain text on stdout.\n' +
764
- '\n' +
765
- 'stdin { source?: string, path?: string, width?: int=process.stdout.columns||100, color?: bool=true }\n' +
766
- 'stdout the rendered text (raw bytes; not JSON)\n' +
767
- 'exit 0 on success, non-zero on bad input\n' +
768
- '\n' +
769
- 'When color=false, ANSI escape sequences are stripped from the output.\n' +
770
- 'Use this for feeding rendered content to other agents that need plain\n' +
771
- 'text without color codes.\n')
772
- .helpOption('-h, --help', 'Show help')
773
- .action(() => {
774
- const input = parseStdinJson();
775
- const src = resolveDocSource(input);
776
- const width = typeof input.width === 'number' && input.width > 0
777
- ? input.width
778
- : (process.stdout.columns || 100);
779
- const lines = renderMarkdown(src, width);
780
- let out = lines.join('\n');
781
- if (input.color === false) {
782
- // Strip ANSI escape sequences for plain-text consumers.
783
- // eslint-disable-next-line no-control-regex
784
- out = out.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
785
- }
786
- process.stdout.write(out);
787
- if (!out.endsWith('\n'))
788
- process.stdout.write('\n');
789
- process.exit(0);
790
- });
791
- function resolveDocSource(input) {
792
- const hasSource = typeof input.source === 'string' && input.source.length > 0;
793
- const hasPath = typeof input.path === 'string' && input.path.length > 0;
794
- if (hasSource === hasPath) {
795
- emitError({
796
- error: 'bad_input',
797
- message: 'provide exactly one of {source, path}',
798
- next: 'stdin like {"source": "..."} or {"path": "/abs/file.md"}',
799
- });
800
- }
801
- if (hasSource)
802
- return input.source;
803
- const abs = resolve(input.path);
804
- if (!existsSync(abs)) {
805
- emitError({ error: 'file_not_found', message: `path not found: ${abs}`, next: 'check the path' });
806
- }
807
- return readFileSync(abs, 'utf-8');
808
- }
809
- // ── inbox ─────────────────────────────────────────────────────────────────────
810
- const inboxCmd = program.command('inbox').description('Browse and resolve pending interactions across root dirs.');
811
- inboxCmd
812
- .command('list')
813
- .description('Read-only paginated query of pending interactions.\n' +
814
- '\n' +
815
- 'stdin { roots: string[] (required, ≥1), limit?: int=20 (max 100), cursor?: string|null }\n' +
816
- 'stdout { items: [{dir,title,askedBy,blockedSince,interactionCount}],\n' +
817
- ' next_cursor: string|null, total: int|null }\n' +
818
- 'Sorted by blockedSince ascending.\n')
819
- .helpOption('-h, --help', 'Show help')
820
- .action(() => {
821
- const input = parseStdinJson();
822
- if (!Array.isArray(input.roots) || input.roots.length === 0) {
823
- emitError({ error: 'bad_input', message: 'roots must be a non-empty array', field: 'roots', next: 'Provide: {"roots": ["/path/to/inbox"]}' });
86
+ const registration = options.root === undefined ? managedInboxRoot() : requireRegisteredRoot(options.root);
87
+ emit({ ...submitDeck({ root: registration.root, id: typeof body.id === 'string' ? body.id : randomUUID(), deck }), queued: true });
824
88
  }
825
- const roots = input.roots.map((r) => resolve(r));
826
- const limit = Math.min(typeof input.limit === 'number' ? input.limit : 20, 100);
827
- const allItems = scanInbox(roots);
828
- const total = allItems.length;
829
- let startIdx = 0;
830
- if (input.cursor) {
831
- const idx = allItems.findIndex((it) => it.dir === input.cursor);
832
- startIdx = idx >= 0 ? idx : 0;
89
+ catch (error) {
90
+ fail(error);
833
91
  }
834
- const page = allItems.slice(startIdx, startIdx + limit);
835
- const nextCursor = startIdx + limit < total ? allItems[startIdx + limit]?.dir : null;
836
- const items = page.map((it) => {
837
- const dk = readJson(deckPath(it.dir));
838
- return {
839
- dir: it.dir,
840
- title: it.title,
841
- askedBy: it.source?.askedBy,
842
- blockedSince: it.blockedSince,
843
- interactionCount: dk ? dk.interactions.length : 1,
844
- };
845
- });
846
- process.stdout.write(JSON.stringify({
847
- items,
848
- next_cursor: nextCursor !== undefined ? nextCursor : null,
849
- total,
850
- }) + '\n');
851
- process.exit(0);
852
92
  });
853
- inboxCmd
854
- .command('resolve')
855
- .description('Kickoff: spawn the inbox-walker TUI detached.\n' +
856
- '\n' +
857
- 'stdin { roots: string[] (required) }\n' +
858
- 'stdout { job_id: string, follow_up: string }\n')
859
- .helpOption('-h, --help', 'Show help')
860
- .action(async () => {
861
- const input = parseStdinJson();
862
- if (!Array.isArray(input.roots) || input.roots.length === 0) {
863
- emitError({ error: 'bad_input', message: 'roots must be a non-empty array', field: 'roots', next: 'Provide: {"roots": ["/path/to/inbox"]}' });
864
- }
865
- const roots = input.roots.map((r) => resolve(r));
866
- const jobDir = mkdtempSync(join(tmpdir(), 'hl-inbox-'));
867
- const jobId = basename(jobDir);
868
- appendJobLog(jobDir, { level: 'info', event: 'job_started', message: 'inbox resolve job started', data: { jobId, roots } });
93
+ program.command('review').command('open').description('Submit a durable anchored review; it never changes tmux. --inline blocks in this terminal instead.').option('--root <path>').option('--inline', 'run the review in this terminal and block until submitted').action(async (options) => {
869
94
  try {
870
- await inbox(roots);
871
- appendJobLog(jobDir, { level: 'info', event: 'job_finished', message: 'inbox resolved', data: { jobId } });
872
- process.stdout.write(JSON.stringify({
873
- job_id: jobId,
874
- follow_up: `Inbox walk complete. Call hl job result with stdin {"job_id":"${jobId}"} for summary.`,
875
- }) + '\n');
876
- process.exit(0);
877
- }
878
- catch (inboxErr) {
879
- const msg = inboxErr instanceof Error ? inboxErr.message : String(inboxErr);
880
- appendJobLog(jobDir, { level: 'error', event: 'job_failed', message: msg });
881
- emitError({ error: 'internal', message: msg, next: 'Ensure the roots are valid directories.' });
95
+ const body = objectInput();
96
+ if (typeof body.file !== 'string' || !existsSync(resolve(body.file)))
97
+ throw new Error('file must be an existing markdown path');
98
+ const absFile = resolve(body.file);
99
+ const output = typeof body.output === 'string' ? resolve(body.output) : `${absFile}.feedback.json`;
100
+ if (options.inline) {
101
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
102
+ throw new Error('hl review open --inline requires an interactive TTY; omit --inline to submit a durable ticket');
103
+ emit(await launchReview(absFile, { output, onPropose: (result) => { writeFeedbackResult(output, result); } }));
104
+ return;
105
+ }
106
+ if (typeof body.title !== 'string' || !body.title)
107
+ throw new Error('title is required');
108
+ const registration = options.root === undefined ? managedInboxRoot() : requireRegisteredRoot(options.root);
109
+ emit({ ...submitReview({ root: registration.root, id: typeof body.id === 'string' ? body.id : randomUUID(), review: { file: absFile, output, title: body.title, source: typeof body.source === 'object' && body.source !== null ? body.source : {} } }), queued: true });
110
+ }
111
+ catch (error) {
112
+ fail(error);
882
113
  }
883
114
  });
884
- // ── job ───────────────────────────────────────────────────────────────────────
885
- const jobCmd = program.command('job').description('Inspect, wait on, or cancel running jobs.');
886
- jobCmd
887
- .command('status')
888
- .description('Read-only job state snapshot.\n' +
889
- '\n' +
890
- 'stdin { job_id: string }\n' +
891
- 'stdout { state: "live"|"done"|"failed"|"canceled", kind: "deck"|"review"|"inbox",\n' +
892
- ' age_seconds: number, last_event: {ts,event,message}|null }\n')
893
- .helpOption('-h, --help', 'Show help')
894
- .action(() => {
895
- const input = parseStdinJson();
896
- if (!input.job_id || typeof input.job_id !== 'string') {
897
- emitError({ error: 'bad_input', message: 'job_id is required', field: 'job_id', next: 'Provide: {"job_id": "<id>"}' });
898
- }
899
- const dir = resolveJobDir(input.job_id);
900
- if (!existsSync(dir)) {
901
- emitError({ error: 'job_not_found', message: `Job not found: ${input.job_id}`, next: 'Check the job_id.' });
902
- }
903
- let ageSecs = 0;
115
+ const view = program.command('view').description('Passively display files; never a ticket surface.');
116
+ view.command('show').description('Live-render a file in a tmux pane. Passive, no result.').action(() => {
904
117
  try {
905
- const _st = {};
906
- void _st;
907
- const st = statSync(dir);
908
- ageSecs = Math.round((Date.now() - st.birthtimeMs) / 1000);
118
+ const body = objectInput();
119
+ if (typeof body.path !== 'string' || !body.path)
120
+ throw new Error('path is required');
121
+ const result = display(resolve(body.path), { window: body.window === 'new' ? 'new' : 'split' });
122
+ emit({ pane_id: result.paneId ?? null, reason: result.paneId === undefined ? 'Not in tmux or termrender unavailable.' : null });
909
123
  }
910
- catch (statErr) {
911
- void String(statErr); // stat unavailable — report 0
124
+ catch (error) {
125
+ fail(error);
912
126
  }
913
- const state = detectJobState(dir);
914
- const kind = detectJobKind(dir);
915
- const last = lastLogEvent(dir);
916
- process.stdout.write(JSON.stringify({
917
- state,
918
- kind,
919
- age_seconds: ageSecs,
920
- last_event: last ? { ts: last.ts, event: last.event, message: last.message } : null,
921
- }) + '\n');
922
- process.exit(0);
923
127
  });
924
- function tryReadJobResult(dir, kind) {
925
- if (kind === 'deck') {
926
- const rp = responsePath(dir);
927
- if (!existsSync(rp))
928
- return null;
929
- const raw = tryParseJson(readFileSync(rp, 'utf8'));
930
- if (!raw)
931
- return null;
932
- const dk = readJson(deckPath(dir));
933
- // Prefer the summary persisted at write time. Legacy response.json files
934
- // (written before the summary field existed) lack it — rebuild it from the
935
- // deck if it's still on disk, else fall back to ''.
936
- let summary = typeof raw.summary === 'string' ? raw.summary : '';
937
- if (summary === '' && dk) {
938
- summary = buildSummary(dk, raw.responses);
939
- }
940
- return {
941
- summary,
942
- responsePath: rp,
943
- schema: 'humanloop.response/v2',
944
- responses: raw.responses,
945
- completedAt: raw.completedAt,
946
- _note: dk ? `Deck had ${dk.interactions.length} interaction(s)` : undefined,
947
- };
948
- }
949
- if (kind === 'review') {
950
- const fp = join(dir, 'feedback.json');
951
- if (!existsSync(fp))
952
- return null;
953
- return tryParseJson(readFileSync(fp, 'utf8'));
954
- }
955
- // inbox
956
- const logPath = jobLogPath(dir);
957
- if (!existsSync(logPath))
958
- return null;
959
- if (detectJobState(dir) !== 'done')
960
- return null;
961
- const lines = readLogLines(logPath);
962
- let resolved = 0;
963
- for (const line of lines) {
964
- const entry = tryParseJson(line);
965
- if (entry && entry.event === 'inbox_resolved')
966
- resolved++;
967
- }
968
- return { resolved };
128
+ function docSource(body) {
129
+ const hasSource = typeof body.source === 'string' && body.source.length > 0;
130
+ const hasPath = typeof body.path === 'string' && body.path.length > 0;
131
+ if (hasSource === hasPath)
132
+ throw new Error('provide exactly one of {source, path}');
133
+ if (hasSource)
134
+ return body.source;
135
+ const abs = resolve(body.path);
136
+ if (!existsSync(abs))
137
+ throw new Error(`path not found: ${abs}`);
138
+ return readFileSync(abs, 'utf8');
969
139
  }
970
- jobCmd
971
- .command('result')
972
- .description('Retrieve terminal payload of a finished job.\n' +
973
- '\n' +
974
- 'stdin { job_id: string, wait?: bool=false }\n' +
975
- 'stdout deck → ResolutionEnvelope (humanloop.response/v2)\n' +
976
- ' review → FeedbackResult\n' +
977
- ' inbox → { resolved: int }\n' +
978
- ' not done + wait:false → { error:"not_ready", ... } exit 1\n' +
979
- ' wait:true blocks until sidecar appears or job terminates.\n')
980
- .helpOption('-h, --help', 'Show help')
981
- .action(async () => {
982
- const input = parseStdinJson();
983
- if (!input.job_id || typeof input.job_id !== 'string') {
984
- emitError({ error: 'bad_input', message: 'job_id is required', field: 'job_id', next: 'Provide: {"job_id": "<id>", "wait": true}' });
985
- }
986
- const dir = resolveJobDir(input.job_id);
987
- if (!existsSync(dir)) {
988
- emitError({ error: 'job_not_found', message: `Job not found: ${input.job_id}`, next: 'Check the job_id.' });
989
- }
990
- const wait = input.wait === true;
991
- const kind = detectJobKind(dir);
992
- if (!wait) {
993
- const result = tryReadJobResult(dir, kind);
994
- if (result === null) {
995
- emitError({ error: 'not_ready', message: 'Job is not yet complete.', next: 'Retry with wait:true to block until done.' }, 1);
996
- }
997
- process.stdout.write(JSON.stringify(result) + '\n');
998
- process.exit(0);
999
- }
1000
- await new Promise((resolvePromise) => {
1001
- const poll = setInterval(() => {
1002
- const result = tryReadJobResult(dir, kind);
1003
- if (result !== null) {
1004
- clearInterval(poll);
1005
- process.stdout.write(JSON.stringify(result) + '\n');
1006
- process.exit(0);
1007
- }
1008
- const state = detectJobState(dir);
1009
- if (state === 'failed' || state === 'canceled') {
1010
- clearInterval(poll);
1011
- emitError({ error: 'not_ready', message: `Job ended with state: ${state}`, next: 'Check hl job logs for details.' }, 1);
1012
- }
1013
- }, 200);
1014
- void resolvePromise;
1015
- });
1016
- });
1017
- jobCmd
1018
- .command('logs')
1019
- .description('Stream job.log events (JSONL).\n' +
1020
- '\n' +
1021
- 'stdin { job_id: string, since?: string|null,\n' +
1022
- ' level?: "debug"|"info"|"warn"|"error"="info", follow?: bool=false }\n' +
1023
- 'stdout JSONL — one event per line: { ts, level, event, message, data? }\n' +
1024
- 'follow:false → emit historical then exit; follow:true → stream until done.\n')
1025
- .helpOption('-h, --help', 'Show help')
1026
- .action(async () => {
1027
- const input = parseStdinJson();
1028
- if (!input.job_id || typeof input.job_id !== 'string') {
1029
- emitError({ error: 'bad_input', message: 'job_id is required', field: 'job_id', next: 'Provide: {"job_id": "<id>"}' });
1030
- }
1031
- const dir = resolveJobDir(input.job_id);
1032
- if (!existsSync(dir)) {
1033
- emitError({ error: 'job_not_found', message: `Job not found: ${input.job_id}`, next: 'Check the job_id.' });
1034
- }
1035
- const levelOrder = { debug: 0, info: 1, warn: 2, error: 3 };
1036
- const inputLevel = (input.level && input.level in levelOrder) ? input.level : 'info';
1037
- const minLevel = levelOrder[inputLevel];
1038
- const since = input.since;
1039
- const follow = input.follow === true;
1040
- const logPath = jobLogPath(dir);
1041
- let emittedCount = 0;
1042
- function emitLogLines() {
1043
- const lines = readLogLines(logPath);
1044
- for (let i = emittedCount; i < lines.length; i++) {
1045
- const entry = tryParseJson(lines[i]);
1046
- if (!entry)
1047
- continue;
1048
- if (since && entry.ts <= since)
1049
- continue;
1050
- const entryLevel = entry.level in levelOrder ? levelOrder[entry.level] : 0;
1051
- if (entryLevel < minLevel)
1052
- continue;
1053
- process.stdout.write(JSON.stringify(entry) + '\n');
1054
- }
1055
- emittedCount = lines.length;
1056
- }
1057
- emitLogLines();
1058
- if (!follow) {
1059
- process.exit(0);
1060
- }
1061
- await new Promise((resolvePromise) => {
1062
- const poll = setInterval(() => {
1063
- emitLogLines();
1064
- const state = detectJobState(dir);
1065
- if (state === 'done' || state === 'failed' || state === 'canceled') {
1066
- clearInterval(poll);
1067
- process.exit(0);
1068
- }
1069
- }, 200);
1070
- void resolvePromise;
1071
- });
1072
- });
1073
- jobCmd
1074
- .command('cancel')
1075
- .description('Best-effort cancel: signal the job pane and close it if possible.\n' +
1076
- '\n' +
1077
- 'stdin { job_id: string }\n' +
1078
- 'stdout { canceled: bool, message: string }\n')
1079
- .helpOption('-h, --help', 'Show help')
1080
- .action(() => {
1081
- const input = parseStdinJson();
1082
- if (!input.job_id || typeof input.job_id !== 'string') {
1083
- emitError({ error: 'bad_input', message: 'job_id is required', field: 'job_id', next: 'Provide: {"job_id": "<id>"}' });
1084
- }
1085
- const dir = resolveJobDir(input.job_id);
1086
- if (!existsSync(dir)) {
1087
- emitError({ error: 'job_not_found', message: `Job not found: ${input.job_id}`, next: 'Check the job_id.' });
140
+ const doc = program.command('doc').description('Render or validate directive-flavored markdown to stdout via the managed termrender binary.');
141
+ doc.command('check').description('Validate directive-flavored markdown without rendering.').action(() => {
142
+ try {
143
+ emit(checkMarkdown(docSource(objectInput())));
1088
144
  }
1089
- let canceled = false;
1090
- let message = 'No tmux pane found; signal not delivered (job may already be done).';
1091
- if (process.env['TMUX']) {
1092
- try {
1093
- const panes = execFileSync('tmux', ['list-panes', '-a', '-F', '#{pane_id} #{pane_current_command}'], { encoding: 'utf8' });
1094
- for (const line of panes.split('\n')) {
1095
- const paneId = line.split(' ')[0];
1096
- if (!paneId)
1097
- continue;
1098
- try {
1099
- execFileSync('tmux', ['send-keys', '-t', paneId, 'q', ''], { stdio: 'ignore' });
1100
- }
1101
- catch (sendErr) {
1102
- void String(sendErr); // best-effort per pane
1103
- }
1104
- }
1105
- canceled = true;
1106
- message = 'Signal delivered to tmux pane(s).';
1107
- }
1108
- catch (tmuxErr) {
1109
- message = `tmux pane lookup failed: ${tmuxErr instanceof Error ? tmuxErr.message : String(tmuxErr)}`;
1110
- }
145
+ catch (error) {
146
+ fail(error);
1111
147
  }
1112
- appendJobLog(dir, { level: 'info', event: 'job_canceled', message: `cancel requested: ${message}` });
1113
- process.stdout.write(JSON.stringify({ canceled, message }) + '\n');
1114
- process.exit(0);
1115
148
  });
1116
- // ── schema ────────────────────────────────────────────────────────────────────
1117
- const schemaCmd = program.command('schema').description('Print JSON Schemas for hl data types.');
1118
- schemaCmd
1119
- .command('show')
1120
- .description('Print the JSON Schema for a data kind.\n' +
1121
- '\n' +
1122
- 'stdin { kind?: "deck"|"resolution"|"feedback"="deck" }\n' +
1123
- 'stdout the JSON Schema object\n')
1124
- .helpOption('-h, --help', 'Show help')
1125
- .action(() => {
1126
- const input = parseStdinJson();
1127
- const kind = (typeof input.kind === 'string' && input.kind) ? input.kind : 'deck';
1128
- if (kind === 'resolution') {
1129
- process.stdout.write(JSON.stringify(RESPONSE_SCHEMA, null, 2) + '\n');
1130
- }
1131
- else if (kind === 'feedback') {
1132
- process.stdout.write(JSON.stringify(FEEDBACK_SCHEMA, null, 2) + '\n');
1133
- }
1134
- else if (kind === 'deck') {
1135
- process.stdout.write(JSON.stringify(REQUEST_SCHEMA, null, 2) + '\n');
149
+ doc.command('render').description('Render directive-flavored markdown to ANSI or plain text on stdout.').action(() => {
150
+ try {
151
+ const body = objectInput();
152
+ const source = docSource(body);
153
+ const width = typeof body.width === 'number' && body.width > 0 ? body.width : (process.stdout.columns || 100);
154
+ let out = renderMarkdown(source, width).join('\n');
155
+ if (body.color === false)
156
+ out = out.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
157
+ process.stdout.write(out.endsWith('\n') ? out : `${out}\n`);
1136
158
  }
1137
- else {
1138
- emitError({
1139
- error: 'bad_input',
1140
- message: `Unknown kind: ${kind}. Valid: deck, resolution, feedback`,
1141
- field: 'kind',
1142
- next: 'Provide: {"kind": "deck"} or {"kind": "resolution"} or {"kind": "feedback"}',
1143
- });
159
+ catch (error) {
160
+ fail(error);
1144
161
  }
1145
162
  });
1146
- program.parse();
163
+ program.parseAsync().catch(fail);