@crouton-kit/humanloop 0.2.1 → 0.3.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/api.js +15 -7
- package/dist/cli.js +809 -385
- package/dist/inbox/deck-schema.d.ts +1 -0
- package/dist/inbox/deck-schema.js +1 -0
- package/dist/render/termrender.d.ts +2 -2
- package/dist/render/termrender.js +80 -24
- package/dist/render/version.d.ts +1 -1
- package/dist/render/version.js +1 -1
- package/dist/tui/input.js +67 -8
- package/dist/tui/render.js +32 -9
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,357 +1,195 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { Command
|
|
3
|
-
import { writeFileSync, mkdtempSync } from 'fs';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { launchReview
|
|
9
|
-
import {
|
|
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 } from './editor/review.js';
|
|
9
|
+
import { validateDeck } from './inbox/deck-schema.js';
|
|
10
10
|
import { ask, inbox } from './api.js';
|
|
11
11
|
import { display } from './surfaces/display.js';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
' under a heading the reader can skip.\n' +
|
|
29
|
-
'The reader can connect dots — do not write engineer-to-engineer jargon.\n' +
|
|
30
|
-
'See `hl ask --help` for content guidance and a worked example.\n' +
|
|
31
|
-
'\n' +
|
|
32
|
-
'Workflow:\n' +
|
|
33
|
-
' 1. Write a deck file matching `hl schema` (a JSON object with interactions[]).\n' +
|
|
34
|
-
' 2. Run `hl ask <file>`; it blocks and prints a ResolutionEnvelope JSON to stdout.\n' +
|
|
35
|
-
' 3. Parse the JSON; look up answers by `id` (humans can skip questions).')
|
|
36
|
-
.version('0.1.0')
|
|
37
|
-
.addHelpText('after', '\nExamples:\n' +
|
|
38
|
-
' hl schema # print the input JSON schema\n' +
|
|
39
|
-
' hl schema response # print the resolution-envelope schema\n' +
|
|
40
|
-
' hl ask deck.json # open TUI, block, print envelope JSON\n' +
|
|
41
|
-
' hl ask deck.json --dir /tmp/ix # store progress/response.json in /tmp/ix\n' +
|
|
42
|
-
' hl ask deck.json --no-tmux # run in current pane even inside tmux\n' +
|
|
43
|
-
' hl inbox /tmp/box # resolve pending interactions under a root\n' +
|
|
44
|
-
' hl display plan.md # live-render a file in a tmux pane\n');
|
|
45
|
-
program
|
|
46
|
-
.command('ask')
|
|
47
|
-
.description('Open the decisions TUI on <file> and block until the human finishes review.\n' +
|
|
48
|
-
'Prints a ResolutionEnvelope JSON to stdout (or to --output / --write-to).')
|
|
49
|
-
.argument('<file>', 'Path to deck JSON file (see `hl schema` for format)')
|
|
50
|
-
.option('--dir <interaction-dir>', 'Interaction directory holding progress.json/response.json. Default: a managed temp dir under os.tmpdir().')
|
|
51
|
-
.option('--session-id <id>', 'Claude session ID; enables per-interaction visual context from conversation history. Defaults to the most recent session in cwd.')
|
|
52
|
-
.option('--no-visuals', 'Skip visual context generation (faster, no haiku calls)')
|
|
53
|
-
.option('--output <path>', 'Write result JSON to <path> instead of stdout')
|
|
54
|
-
.option('--no-tmux', 'Do not auto-dispatch the TUI to a new tmux pane even when $TMUX is set')
|
|
55
|
-
.addOption(new Option('--write-to <path>', 'internal: tmux child mode').hideHelp())
|
|
56
|
-
.addHelpText('after', '\n' +
|
|
57
|
-
'CONTENT — what to write in each interaction\n' +
|
|
58
|
-
' The deck is read by a busy, technical human. Use progressive\n' +
|
|
59
|
-
' disclosure so the reader can stop at any layer:\n' +
|
|
60
|
-
'\n' +
|
|
61
|
-
' title Noun-phrase topic (≤4 words). The *thing* being\n' +
|
|
62
|
-
' decided, not the decision. \'Database\' not \'Use\n' +
|
|
63
|
-
' Postgres\'. The reader scans titles like an inbox.\n' +
|
|
64
|
-
' subtitle TL;DR — one plain-English sentence framing the\n' +
|
|
65
|
-
' choice or stakes. Action-ready if the call is\n' +
|
|
66
|
-
' obvious. No jargon, no library names without\n' +
|
|
67
|
-
' context.\n' +
|
|
68
|
-
' body ELI12 markdown. Audience: a smart engineer joining\n' +
|
|
69
|
-
' the codebase — capable, but does not want a wall of\n' +
|
|
70
|
-
' jargon. Lead with what is at stake in plain language.\n' +
|
|
71
|
-
' Tuck anything denser (technical specifics, alternatives\n' +
|
|
72
|
-
' considered, edge cases, related context) under a\n' +
|
|
73
|
-
' heading like `## Details` or `## Alternatives` so the\n' +
|
|
74
|
-
' reader can skip past it. Every layer below the TL;DR\n' +
|
|
75
|
-
' is optional reading.\n' +
|
|
76
|
-
'\n' +
|
|
77
|
-
' AVOID: walls of jargon; raw schema dumps or stack traces in body;\n' +
|
|
78
|
-
' titles that bury the topic; subtitles that restate the title;\n' +
|
|
79
|
-
' options that are not real alternatives; asking the human anything\n' +
|
|
80
|
-
' you could decide yourself from the code.\n' +
|
|
81
|
-
'\n' +
|
|
82
|
-
'INPUT FORMAT\n' +
|
|
83
|
-
' JSON file with an `interactions` array. Each interaction has an `id`,\n' +
|
|
84
|
-
' `title`, `options[]`, and optional `subtitle`, `body`, `allowFreetext`.\n' +
|
|
85
|
-
' Run `hl schema` for the full schema. Example with pyramid content:\n' +
|
|
86
|
-
' {\n' +
|
|
87
|
-
' "interactions": [\n' +
|
|
88
|
-
' {\n' +
|
|
89
|
-
' "id": "db",\n' +
|
|
90
|
-
' "title": "Database",\n' +
|
|
91
|
-
' "subtitle": "Postgres or SQLite for the new capture store?",\n' +
|
|
92
|
-
' "body": "Two services will write at the same time, which is the crux.\\n\\nPostgres handles concurrent writes natively. SQLite serializes them — fine at low traffic, but we expect bursts.\\n\\n## Details\\nSQLite WAL still serializes writers; Postgres uses MVCC.",\n' +
|
|
93
|
-
' "options": [\n' +
|
|
94
|
-
' {"id":"pg","label":"Postgres"},\n' +
|
|
95
|
-
' {"id":"sqlite","label":"SQLite"}\n' +
|
|
96
|
-
' ],\n' +
|
|
97
|
-
' "allowFreetext": true\n' +
|
|
98
|
-
' },\n' +
|
|
99
|
-
' {\n' +
|
|
100
|
-
' "id": "retry",\n' +
|
|
101
|
-
' "title": "Retry policy",\n' +
|
|
102
|
-
' "subtitle": "How aggressively should we retry publish failures?",\n' +
|
|
103
|
-
' "body": "Affects the reliability budget. Too aggressive and we hammer downstream during outages; too lax and transient blips become user-visible.",\n' +
|
|
104
|
-
' "options": [],\n' +
|
|
105
|
-
' "allowFreetext": true\n' +
|
|
106
|
-
' }\n' +
|
|
107
|
-
' ]\n' +
|
|
108
|
-
' }\n' +
|
|
109
|
-
'\n' +
|
|
110
|
-
'OUTPUT FORMAT (stdout on success, JSON — a ResolutionEnvelope)\n' +
|
|
111
|
-
' {\n' +
|
|
112
|
-
' "summary": "<one line per answered interaction>",\n' +
|
|
113
|
-
' "responsePath": "/abs/path/to/<dir>/response.json",\n' +
|
|
114
|
-
' "schema": "humanloop.response/v2",\n' +
|
|
115
|
-
' "responses": [ ... ],\n' +
|
|
116
|
-
' "completedAt": "2026-04-20T15:23:00.000Z"\n' +
|
|
117
|
-
' }\n' +
|
|
118
|
-
'\n' +
|
|
119
|
-
' On-disk <dir>/response.json holds only { responses, completedAt }.\n' +
|
|
120
|
-
' Run `hl schema response` for the envelope schema.\n' +
|
|
121
|
-
'\n' +
|
|
122
|
-
' Response shape:\n' +
|
|
123
|
-
' { id: string, selectedOptionId?: string, freetext?: string }\n' +
|
|
124
|
-
'\n' +
|
|
125
|
-
' The human can skip interactions. `responses` may have FEWER entries than\n' +
|
|
126
|
-
' input interactions — look up by `id`, do not assume index alignment.\n' +
|
|
127
|
-
'\n' +
|
|
128
|
-
'BEHAVIOR\n' +
|
|
129
|
-
' tmux When $TMUX is set, the TUI auto-splits into a new pane to the\n' +
|
|
130
|
-
' right (-d keeps focus on the caller). Disable with --no-tmux.\n' +
|
|
131
|
-
' storage progress.json/response.json live in --dir (default: a managed\n' +
|
|
132
|
-
' temp dir). Responses persist atomically after every change;\n' +
|
|
133
|
-
' a hard kill resumes from progress.json on the next run.\n' +
|
|
134
|
-
' response.json is written when the human finishes.\n' +
|
|
135
|
-
' visuals With --session-id (or auto-detected) haiku generates a short\n' +
|
|
136
|
-
' ANSI context block per interaction from recent conversation turns.\n' +
|
|
137
|
-
'\n' +
|
|
138
|
-
'EXIT CODES\n' +
|
|
139
|
-
' 0 success — result JSON emitted\n' +
|
|
140
|
-
' 1 error — message on stderr (file missing, invalid JSON, empty\n' +
|
|
141
|
-
' interactions, no TTY, etc.)\n')
|
|
142
|
-
.action(async (file, opts) => {
|
|
143
|
-
const sessionId = opts.visuals
|
|
144
|
-
? (opts.sessionId || findRecentSessionId(process.cwd()) || findRecentSessionId() || undefined)
|
|
145
|
-
: undefined;
|
|
146
|
-
const dir = opts.dir ? resolve(opts.dir) : mkdtempSync(join(tmpdir(), 'hl-ix-'));
|
|
147
|
-
const emit = (result) => {
|
|
148
|
-
const json = JSON.stringify(result, null, 2) + '\n';
|
|
149
|
-
if (opts.writeTo) {
|
|
150
|
-
writeFileSync(opts.writeTo, json);
|
|
151
|
-
}
|
|
152
|
-
else if (opts.output) {
|
|
153
|
-
writeFileSync(opts.output, json);
|
|
154
|
-
}
|
|
155
|
-
else {
|
|
156
|
-
process.stdout.write(json);
|
|
157
|
-
}
|
|
158
|
-
};
|
|
12
|
+
import { scanInbox } from './inbox/scan.js';
|
|
13
|
+
import { deckPath, atomicWriteJson, readJson, responsePath, } from './inbox/convention.js';
|
|
14
|
+
// ── Version ───────────────────────────────────────────────────────────────────
|
|
15
|
+
const HL_VERSION = '0.2.1';
|
|
16
|
+
function emitError(err, exitCode = 1) {
|
|
17
|
+
process.stdout.write(JSON.stringify(err) + '\n');
|
|
18
|
+
process.exit(exitCode);
|
|
19
|
+
}
|
|
20
|
+
function emitStderrError(err, exitCode = 1) {
|
|
21
|
+
process.stderr.write(JSON.stringify(err) + '\n');
|
|
22
|
+
process.exit(exitCode);
|
|
23
|
+
}
|
|
24
|
+
// ── stdin reader ──────────────────────────────────────────────────────────────
|
|
25
|
+
function readStdin() {
|
|
26
|
+
let content = '';
|
|
27
|
+
const stdinResult = { ok: false, value: '' };
|
|
159
28
|
try {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
process.exit(0);
|
|
165
|
-
}
|
|
166
|
-
catch (err) {
|
|
167
|
-
process.stderr.write(`tmux dispatch failed, running locally: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
const deck = parseDeck(resolve(file));
|
|
171
|
-
const result = await ask(deck, { dir, sessionId });
|
|
172
|
-
emit(result);
|
|
173
|
-
process.exit(0);
|
|
29
|
+
const _io = {};
|
|
30
|
+
void _io;
|
|
31
|
+
stdinResult.value = readFileSync('/dev/stdin', 'utf8');
|
|
32
|
+
stdinResult.ok = true;
|
|
174
33
|
}
|
|
175
|
-
catch (
|
|
176
|
-
|
|
177
|
-
process.stderr.write(`ERROR: ${msg}\n`);
|
|
178
|
-
if (msg.includes('ENOENT') || msg.includes('no such file')) {
|
|
179
|
-
process.stderr.write('\nFix: pass a path to an existing deck JSON file.\nSee format: hl schema\n');
|
|
180
|
-
}
|
|
181
|
-
else if (msg.includes('not valid JSON') || msg.includes('JSON')) {
|
|
182
|
-
process.stderr.write('\nFix: the deck file must be valid JSON matching `hl schema`.\n');
|
|
183
|
-
}
|
|
184
|
-
else if (msg.includes('TTY')) {
|
|
185
|
-
process.stderr.write('\nFix: hl needs an interactive terminal. If the caller captures stdin,\nrun inside tmux so hl can auto-dispatch the TUI to a new pane, or pipe\nstdin from /dev/tty.\n');
|
|
186
|
-
}
|
|
187
|
-
else {
|
|
188
|
-
process.stderr.write('\nFix: the deck file must match `hl schema`. Run `hl schema` to see the required shape.\n');
|
|
189
|
-
}
|
|
190
|
-
process.exit(1);
|
|
34
|
+
catch (stdinErr) {
|
|
35
|
+
process.stderr.write(`[hl] stdin read error: ${stdinErr instanceof Error ? stdinErr.message : String(stdinErr)}\n`);
|
|
191
36
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
'EXIT CODES\n' +
|
|
207
|
-
' 0 finished (human quit, or nothing pending)\n' +
|
|
208
|
-
' 1 error (no TTY, unreadable root, etc.)\n' +
|
|
209
|
-
'\n' +
|
|
210
|
-
'Examples:\n' +
|
|
211
|
-
' hl inbox /tmp/box\n' +
|
|
212
|
-
' hl inbox ~/.sisyphus/asks ~/.crtr/pending\n')
|
|
213
|
-
.action(async (roots) => {
|
|
37
|
+
content = stdinResult.value;
|
|
38
|
+
return content;
|
|
39
|
+
}
|
|
40
|
+
function parseStdinJson() {
|
|
41
|
+
const raw = readStdin().trim();
|
|
42
|
+
if (!raw) {
|
|
43
|
+
emitStderrError({
|
|
44
|
+
error: 'bad_stdin_json',
|
|
45
|
+
message: 'No input on stdin. Expected a JSON object.',
|
|
46
|
+
next: "Pipe a JSON object to stdin, e.g.: echo '{\"deck\":{...}}' | hl deck ask",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
let parsed;
|
|
50
|
+
const parseResult = { ok: false };
|
|
214
51
|
try {
|
|
215
|
-
|
|
216
|
-
|
|
52
|
+
const _p = {};
|
|
53
|
+
void _p;
|
|
54
|
+
parseResult.value = JSON.parse(raw);
|
|
55
|
+
parseResult.ok = true;
|
|
217
56
|
}
|
|
218
|
-
catch (
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
57
|
+
catch (parseErr) {
|
|
58
|
+
emitStderrError({
|
|
59
|
+
error: 'bad_stdin_json',
|
|
60
|
+
message: `stdin is not valid JSON: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`,
|
|
61
|
+
received: raw.slice(0, 200),
|
|
62
|
+
next: 'Pipe a valid JSON object to stdin.',
|
|
63
|
+
});
|
|
225
64
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
' hl display plan.md --no-watch\n' +
|
|
243
|
-
' hl display plan.md --new-window\n')
|
|
244
|
-
.action((path, opts) => {
|
|
245
|
-
const res = display(resolve(path), {
|
|
246
|
-
watch: opts.watch,
|
|
247
|
-
window: opts.newWindow ? 'new' : 'auto',
|
|
248
|
-
});
|
|
249
|
-
if (res.paneId) {
|
|
250
|
-
process.stdout.write(`opened in tmux pane ${res.paneId} (live — edits to the file refresh the view)\n`);
|
|
65
|
+
if (parseResult.ok)
|
|
66
|
+
parsed = parseResult.value;
|
|
67
|
+
return parsed;
|
|
68
|
+
}
|
|
69
|
+
function jobLogPath(dir) {
|
|
70
|
+
return join(dir, 'job.log');
|
|
71
|
+
}
|
|
72
|
+
function appendJobLog(dir, entry) {
|
|
73
|
+
const line = { ts: new Date().toISOString(), ...entry };
|
|
74
|
+
const serialized = JSON.stringify(line) + '\n';
|
|
75
|
+
const logResult = { wrote: false };
|
|
76
|
+
try {
|
|
77
|
+
const _l = {};
|
|
78
|
+
void _l;
|
|
79
|
+
appendFileSync(jobLogPath(dir), serialized);
|
|
80
|
+
logResult.wrote = true;
|
|
251
81
|
}
|
|
252
|
-
|
|
253
|
-
|
|
82
|
+
catch (writeErr) {
|
|
83
|
+
void Object.assign(logResult, { error: String(writeErr) }); // best-effort; never throws
|
|
254
84
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
.
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
.option('--no-tmux', 'Run the editor in the current terminal instead of a tmux split pane')
|
|
267
|
-
.addHelpText('after', '\n' +
|
|
268
|
-
'WHAT THIS IS FOR\n' +
|
|
269
|
-
' Freeform review of a markdown doc you wrote. The human leaves comments\n' +
|
|
270
|
-
' anchored to real source lines or visual selections using native vim\n' +
|
|
271
|
-
' keybindings — you do not predefine questions; the human tells you what\n' +
|
|
272
|
-
' is wrong and where.\n' +
|
|
273
|
-
'\n' +
|
|
274
|
-
'BEHAVIOR\n' +
|
|
275
|
-
' • Opens the file READ-ONLY in a CLEAN editor (nvim -u NONE: no\n' +
|
|
276
|
-
' init.lua / LazyVim / plugins / keymaps). Look/feel is ONLY the\n' +
|
|
277
|
-
' user\'s gloam colorscheme + built-in treesitter markdown\n' +
|
|
278
|
-
' highlighting. Review keys are buffer-scoped on <Space>,\n' +
|
|
279
|
-
' plus :HL* commands.\n' +
|
|
280
|
-
' • When $TMUX is set, opens in a split pane (disable with --no-tmux);\n' +
|
|
281
|
-
' otherwise takes over the current terminal.\n' +
|
|
282
|
-
' • Comments autosave to the answers JSON continuously. A killed/closed\n' +
|
|
283
|
-
' session RESUMES from the autosave on next run.\n' +
|
|
284
|
-
' • ANY quit submits. On editor exit the JSON is finalized (submitted:true)\n' +
|
|
285
|
-
' and echoed to stdout, then the command exits 0.\n' +
|
|
286
|
-
' • Quitting with zero comments sets approved:true ("looks good").\n' +
|
|
287
|
-
'\n' +
|
|
288
|
-
' In-editor (<Space> maps; or use the :HL* commands):\n' +
|
|
289
|
-
' <Space>c / :HLComment Comment on the visual selection or current line\n' +
|
|
290
|
-
' <Space>l / :HLList Toggle comments list\n' +
|
|
291
|
-
' <Space>u / :HLUndo Undo last comment\n' +
|
|
292
|
-
' <Space>s / :HLSubmit Submit & quit\n' +
|
|
293
|
-
'\n' +
|
|
294
|
-
'OUTPUT\n' +
|
|
295
|
-
' stdout is a COMPACT listing (kept small so it does not clog context):\n' +
|
|
296
|
-
' a header with the source path, then per comment:\n' +
|
|
297
|
-
'\n' +
|
|
298
|
-
' 1. L46:5-35\n' +
|
|
299
|
-
' text: <the original text in that span>\n' +
|
|
300
|
-
' comment: <the human\'s note>\n' +
|
|
301
|
-
'\n' +
|
|
302
|
-
' Range is L<line> (whole line), L<line>:<colStart>-<colEnd> (partial\n' +
|
|
303
|
-
' selection), or L<l1>-<l2> / L<l1>:<c1>-<l2>:<c2> across lines. text\n' +
|
|
304
|
-
' is the exact selected quote, or the whole line(s) when there was no\n' +
|
|
305
|
-
' partial selection. Zero comments => "approved" (looks good, proceed).\n' +
|
|
306
|
-
'\n' +
|
|
307
|
-
' The FULL record is written to --output (default <file>.feedback.json);\n' +
|
|
308
|
-
' stdout ends with its path + this schema (read the file only if you\n' +
|
|
309
|
-
' actually need the verbose fields — usually you do not):\n' +
|
|
310
|
-
' {file, submitted, approved,\n' +
|
|
311
|
-
' comments:[{id, line, endLine, quote?, colStart?, colEnd?,\n' +
|
|
312
|
-
' lineText, comment, createdAt}],\n' +
|
|
313
|
-
' submittedAt, savedAt}\n' +
|
|
314
|
-
' cols are 0-based byte offsets, colEnd exclusive. Act on each comment\n' +
|
|
315
|
-
' via the source path + range (quote/cols when present).\n' +
|
|
316
|
-
'\n' +
|
|
317
|
-
'EXIT CODES\n' +
|
|
318
|
-
' 0 finished — feedback summary emitted\n' +
|
|
319
|
-
' 1 error (file missing, no nvim/vim found)\n' +
|
|
320
|
-
'\n' +
|
|
321
|
-
'Examples:\n' +
|
|
322
|
-
' hl propose plan.md\n' +
|
|
323
|
-
' hl propose plan.md --output /tmp/fb.json\n' +
|
|
324
|
-
' hl propose plan.md --no-tmux\n' +
|
|
325
|
-
' hl propose plan.md --editor vim\n')
|
|
326
|
-
.action(async (file, opts) => {
|
|
85
|
+
}
|
|
86
|
+
// ── job dir resolution ────────────────────────────────────────────────────────
|
|
87
|
+
function resolveJobDir(jobId) {
|
|
88
|
+
if (jobId.startsWith('/'))
|
|
89
|
+
return jobId;
|
|
90
|
+
const td = tmpdir();
|
|
91
|
+
const candidate = join(td, jobId);
|
|
92
|
+
if (existsSync(candidate))
|
|
93
|
+
return candidate;
|
|
94
|
+
let entries = [];
|
|
95
|
+
const scanResult = { entries: [] };
|
|
327
96
|
try {
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
process.exit(0);
|
|
97
|
+
const _s = {};
|
|
98
|
+
void _s;
|
|
99
|
+
scanResult.entries = readdirSync(td);
|
|
332
100
|
}
|
|
333
|
-
catch (
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
101
|
+
catch (readdirErr) {
|
|
102
|
+
void Object.assign(scanResult, { error: String(readdirErr) }); // opportunistic scan
|
|
103
|
+
return candidate;
|
|
104
|
+
}
|
|
105
|
+
entries = scanResult.entries;
|
|
106
|
+
for (const e of entries) {
|
|
107
|
+
if (e === jobId || basename(e) === jobId) {
|
|
108
|
+
const full = join(td, e);
|
|
109
|
+
if (existsSync(join(full, 'deck.json')))
|
|
110
|
+
return full;
|
|
338
111
|
}
|
|
339
|
-
|
|
340
|
-
|
|
112
|
+
}
|
|
113
|
+
return candidate;
|
|
114
|
+
}
|
|
115
|
+
function detectJobKind(dir) {
|
|
116
|
+
if (existsSync(join(dir, 'deck.json')))
|
|
117
|
+
return 'deck';
|
|
118
|
+
if (existsSync(join(dir, 'feedback.json')) || existsSync(join(dir, 'review.vim')))
|
|
119
|
+
return 'review';
|
|
120
|
+
return 'inbox';
|
|
121
|
+
}
|
|
122
|
+
function tryParseJson(text) {
|
|
123
|
+
try {
|
|
124
|
+
const _j = {};
|
|
125
|
+
void _j;
|
|
126
|
+
return JSON.parse(text);
|
|
127
|
+
}
|
|
128
|
+
catch (parseErr) {
|
|
129
|
+
void String(parseErr);
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function readLogLines(logPath) {
|
|
134
|
+
try {
|
|
135
|
+
const _r = {};
|
|
136
|
+
void _r;
|
|
137
|
+
return readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean);
|
|
138
|
+
}
|
|
139
|
+
catch (readErr) {
|
|
140
|
+
void String(readErr);
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function detectJobState(dir) {
|
|
145
|
+
if (existsSync(join(dir, 'response.json')))
|
|
146
|
+
return 'done';
|
|
147
|
+
if (existsSync(join(dir, 'feedback.json'))) {
|
|
148
|
+
const fb = tryParseJson(readFileSync(join(dir, 'feedback.json'), 'utf8'));
|
|
149
|
+
if (fb && fb.submitted)
|
|
150
|
+
return 'done';
|
|
151
|
+
}
|
|
152
|
+
const logPath = jobLogPath(dir);
|
|
153
|
+
if (existsSync(logPath)) {
|
|
154
|
+
const lines = readLogLines(logPath);
|
|
155
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
156
|
+
const entry = tryParseJson(lines[i]);
|
|
157
|
+
if (!entry)
|
|
158
|
+
continue;
|
|
159
|
+
if (entry.event === 'job_failed')
|
|
160
|
+
return 'failed';
|
|
161
|
+
if (entry.event === 'job_canceled')
|
|
162
|
+
return 'canceled';
|
|
163
|
+
if (entry.event === 'job_finished')
|
|
164
|
+
return 'done';
|
|
341
165
|
}
|
|
342
|
-
process.exit(1);
|
|
343
166
|
}
|
|
344
|
-
|
|
167
|
+
return 'live';
|
|
168
|
+
}
|
|
169
|
+
function lastLogEvent(dir) {
|
|
170
|
+
const logPath = jobLogPath(dir);
|
|
171
|
+
if (!existsSync(logPath))
|
|
172
|
+
return null;
|
|
173
|
+
const lines = readLogLines(logPath);
|
|
174
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
175
|
+
const entry = tryParseJson(lines[i]);
|
|
176
|
+
if (entry)
|
|
177
|
+
return entry;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
// ── tmux child-mode env var (internal, not a CLI flag) ───────────────────────
|
|
182
|
+
// When the parent dispatches to a tmux pane, it sets HL_WRITE_TO=<path> so the
|
|
183
|
+
// child writes its result there instead of stdout. Implementation detail only.
|
|
184
|
+
const INTERNAL_WRITE_TO = process.env['HL_WRITE_TO'];
|
|
185
|
+
// ── Schemas ───────────────────────────────────────────────────────────────────
|
|
345
186
|
const REQUEST_SCHEMA = {
|
|
346
187
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
347
|
-
description: 'Input schema for hl ask (v2)',
|
|
188
|
+
description: 'Input schema for hl deck ask (v2)',
|
|
348
189
|
type: 'object',
|
|
349
190
|
required: ['interactions'],
|
|
350
191
|
properties: {
|
|
351
|
-
title: {
|
|
352
|
-
type: 'string',
|
|
353
|
-
description: 'Optional deck title shown in the TUI header',
|
|
354
|
-
},
|
|
192
|
+
title: { type: 'string', description: 'Optional deck title shown in the TUI header' },
|
|
355
193
|
interactions: {
|
|
356
194
|
type: 'array',
|
|
357
195
|
minItems: 1,
|
|
@@ -359,14 +197,13 @@ const REQUEST_SCHEMA = {
|
|
|
359
197
|
type: 'object',
|
|
360
198
|
required: ['id', 'title', 'options'],
|
|
361
199
|
properties: {
|
|
362
|
-
id: { type: 'string', description: 'Unique identifier.
|
|
363
|
-
title: { type: 'string', description: 'Noun-phrase topic (≤4 words)
|
|
364
|
-
subtitle: { type: 'string'
|
|
365
|
-
body: { type: 'string', description: 'ELI12 markdown
|
|
366
|
-
bodyPath: { type: 'string', description: 'Path to a markdown file used in place of
|
|
200
|
+
id: { type: 'string', description: 'Unique identifier.' },
|
|
201
|
+
title: { type: 'string', description: 'Noun-phrase topic (≤4 words).' },
|
|
202
|
+
subtitle: { type: 'string' },
|
|
203
|
+
body: { type: 'string', description: 'ELI12 markdown body.' },
|
|
204
|
+
bodyPath: { type: 'string', description: 'Path to a markdown file used in place of body.' },
|
|
367
205
|
options: {
|
|
368
206
|
type: 'array',
|
|
369
|
-
description: 'Selectable choices. Empty for freetext-only interactions.',
|
|
370
207
|
items: {
|
|
371
208
|
type: 'object',
|
|
372
209
|
required: ['id', 'label'],
|
|
@@ -374,26 +211,13 @@ const REQUEST_SCHEMA = {
|
|
|
374
211
|
id: { type: 'string' },
|
|
375
212
|
label: { type: 'string' },
|
|
376
213
|
description: { type: 'string' },
|
|
377
|
-
shortcut: {
|
|
378
|
-
type: 'string',
|
|
379
|
-
description: 'Single char shortcut. Auto-assigned if absent. Avoid: c r n p q j k space',
|
|
380
|
-
},
|
|
214
|
+
shortcut: { type: 'string', description: 'Single char. Auto-assigned if absent.' },
|
|
381
215
|
},
|
|
382
216
|
},
|
|
383
217
|
},
|
|
384
|
-
allowFreetext: {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
},
|
|
388
|
-
freetextLabel: {
|
|
389
|
-
type: 'string',
|
|
390
|
-
description: 'Prompt shown above the freetext input. Default: "Comment" or "Response"',
|
|
391
|
-
},
|
|
392
|
-
kind: {
|
|
393
|
-
type: 'string',
|
|
394
|
-
enum: ['notify', 'validation', 'decision', 'context', 'error'],
|
|
395
|
-
description: 'Display hint — opaque to humanloop, used by consumers for inbox icons',
|
|
396
|
-
},
|
|
218
|
+
allowFreetext: { type: 'boolean' },
|
|
219
|
+
freetextLabel: { type: 'string' },
|
|
220
|
+
kind: { type: 'string', enum: ['notify', 'validation', 'decision', 'context', 'error'] },
|
|
397
221
|
},
|
|
398
222
|
},
|
|
399
223
|
},
|
|
@@ -402,54 +226,654 @@ const REQUEST_SCHEMA = {
|
|
|
402
226
|
const RESPONSE_SCHEMA = {
|
|
403
227
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
404
228
|
$id: 'humanloop.response/v2',
|
|
405
|
-
description: 'Resolution envelope emitted by
|
|
229
|
+
description: 'Resolution envelope emitted by hl deck ask / returned by ask().',
|
|
406
230
|
type: 'object',
|
|
407
231
|
required: ['summary', 'responsePath', 'schema', 'responses', 'completedAt'],
|
|
408
232
|
properties: {
|
|
409
|
-
summary: {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
},
|
|
413
|
-
responsePath: {
|
|
414
|
-
type: 'string',
|
|
415
|
-
description: 'Absolute path to the on-disk response.json ({ responses, completedAt }).',
|
|
416
|
-
},
|
|
417
|
-
schema: {
|
|
418
|
-
const: 'humanloop.response/v2',
|
|
419
|
-
description: 'Identifies this response contract.',
|
|
420
|
-
},
|
|
233
|
+
summary: { type: 'string' },
|
|
234
|
+
responsePath: { type: 'string' },
|
|
235
|
+
schema: { const: 'humanloop.response/v2' },
|
|
421
236
|
responses: {
|
|
422
237
|
type: 'array',
|
|
423
|
-
description: 'Inline answers. May have FEWER entries than input interactions — humans can skip. Look up by id.',
|
|
424
238
|
items: {
|
|
425
239
|
type: 'object',
|
|
426
240
|
required: ['id'],
|
|
427
241
|
properties: {
|
|
428
242
|
id: { type: 'string' },
|
|
429
243
|
selectedOptionId: { type: 'string' },
|
|
244
|
+
selectedOptionIds: { type: 'array', items: { type: 'string' } },
|
|
430
245
|
freetext: { type: 'string' },
|
|
431
246
|
},
|
|
432
247
|
},
|
|
433
248
|
},
|
|
434
|
-
completedAt: {
|
|
435
|
-
|
|
436
|
-
|
|
249
|
+
completedAt: { type: 'string' },
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
const FEEDBACK_SCHEMA = {
|
|
253
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
254
|
+
description: 'FeedbackResult written by hl review open / launchReview().',
|
|
255
|
+
type: 'object',
|
|
256
|
+
required: ['file', 'submitted', 'approved', 'comments', 'savedAt'],
|
|
257
|
+
properties: {
|
|
258
|
+
file: { type: 'string', description: 'Absolute path to the reviewed markdown file.' },
|
|
259
|
+
submitted: { type: 'boolean' },
|
|
260
|
+
approved: { type: 'boolean', description: 'True when submitted with zero comments.' },
|
|
261
|
+
comments: {
|
|
262
|
+
type: 'array',
|
|
263
|
+
items: {
|
|
264
|
+
type: 'object',
|
|
265
|
+
required: ['id', 'line', 'endLine', 'lineText', 'comment', 'createdAt'],
|
|
266
|
+
properties: {
|
|
267
|
+
id: { type: 'string' },
|
|
268
|
+
line: { type: 'integer', description: '1-based source line (start).' },
|
|
269
|
+
endLine: { type: 'integer', description: '1-based source line (end).' },
|
|
270
|
+
quote: { type: 'string' },
|
|
271
|
+
colStart: { type: 'integer' },
|
|
272
|
+
colEnd: { type: 'integer' },
|
|
273
|
+
lineText: { type: 'string' },
|
|
274
|
+
comment: { type: 'string' },
|
|
275
|
+
createdAt: { type: 'string' },
|
|
276
|
+
},
|
|
277
|
+
},
|
|
437
278
|
},
|
|
279
|
+
submittedAt: { type: 'string' },
|
|
280
|
+
savedAt: { type: 'string' },
|
|
438
281
|
},
|
|
439
282
|
};
|
|
283
|
+
// ── Commander tree ────────────────────────────────────────────────────────────
|
|
284
|
+
const program = new Command();
|
|
440
285
|
program
|
|
441
|
-
.
|
|
442
|
-
.description(
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
'
|
|
446
|
-
'
|
|
286
|
+
.name('hl')
|
|
287
|
+
.description(`hl ${HL_VERSION} — human-in-the-loop TUI bridge for agents\n` +
|
|
288
|
+
'\n' +
|
|
289
|
+
'I/O contract: every leaf reads ONE JSON object from stdin; writes ONE JSON\n' +
|
|
290
|
+
'object (or JSONL for streams) to stdout; exits 0 on success, non-zero on\n' +
|
|
291
|
+
'error. Errors are always a JSON object on stdout:\n' +
|
|
292
|
+
' { error: <code>, message, next } — codes: bad_stdin_json | bad_input |\n' +
|
|
293
|
+
' deck_invalid | file_not_found | editor_not_found | job_not_found |\n' +
|
|
294
|
+
' not_ready | not_in_tmux | internal\n' +
|
|
295
|
+
'\n' +
|
|
296
|
+
'Concepts:\n' +
|
|
297
|
+
' deck — structured set of interactions (questions) for the human\n' +
|
|
298
|
+
' review — freeform markdown document review with anchored comments\n' +
|
|
299
|
+
' view — passive live render of a file in a tmux pane\n' +
|
|
300
|
+
' inbox — list/resolve all pending interactions across root dirs\n' +
|
|
301
|
+
' job — a running or completed kickoff (deck ask / review / inbox)\n' +
|
|
302
|
+
' schema — JSON Schema for deck, resolution, or feedback payloads\n' +
|
|
303
|
+
'\n' +
|
|
304
|
+
'Subtrees:\n' +
|
|
305
|
+
' hl deck — write questions, get answers | use when: material decisions\n' +
|
|
306
|
+
' hl review — markdown doc review | use when: doc feedback needed\n' +
|
|
307
|
+
' hl view — live render in pane | use when: displaying a file\n' +
|
|
308
|
+
' hl inbox — browse pending interactions | use when: clearing a backlog\n' +
|
|
309
|
+
' hl job — inspect/wait/cancel running jobs | use when: polling job output\n' +
|
|
310
|
+
' hl schema — print JSON Schemas | use when: validating inputs\n' +
|
|
311
|
+
'\n' +
|
|
312
|
+
'Globals: -h / --help on any node.\n')
|
|
313
|
+
.helpOption('-h, --help', 'Show help')
|
|
314
|
+
.addHelpCommand(false);
|
|
315
|
+
// ── deck ──────────────────────────────────────────────────────────────────────
|
|
316
|
+
const deckCmd = program.command('deck').description('Write questions, get answers from the human.');
|
|
317
|
+
deckCmd
|
|
318
|
+
.command('ask')
|
|
319
|
+
.description('Kickoff: spawn the decisions TUI and return immediately.\n' +
|
|
320
|
+
'\n' +
|
|
321
|
+
'stdin { deck: object (required), dir?: string|null,\n' +
|
|
322
|
+
' sessionId?: string|null, visuals?: bool=true, tmux?: bool=true }\n' +
|
|
323
|
+
'stdout { job_id: string, dir: string (absolute), follow_up: string }\n' +
|
|
324
|
+
'\n' +
|
|
325
|
+
'Effects: writes <dir>/deck.json, <dir>/progress.json (live),\n' +
|
|
326
|
+
' <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')
|
|
328
|
+
.helpOption('-h, --help', 'Show help')
|
|
329
|
+
.action(async () => {
|
|
330
|
+
const input = parseStdinJson();
|
|
331
|
+
if (!input.deck || typeof input.deck !== 'object') {
|
|
332
|
+
emitError({
|
|
333
|
+
error: 'bad_input',
|
|
334
|
+
message: 'deck is required and must be an object',
|
|
335
|
+
field: 'deck',
|
|
336
|
+
next: "Run: echo '{\"kind\":\"deck\"}' | hl schema show",
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
let deck;
|
|
340
|
+
try {
|
|
341
|
+
const _v = {};
|
|
342
|
+
void _v;
|
|
343
|
+
deck = validateDeck(input.deck);
|
|
344
|
+
}
|
|
345
|
+
catch (validationErr) {
|
|
346
|
+
emitError({
|
|
347
|
+
error: 'deck_invalid',
|
|
348
|
+
message: `deck validation failed: ${validationErr instanceof Error ? validationErr.message : String(validationErr)}`,
|
|
349
|
+
received: input.deck,
|
|
350
|
+
next: "Fix the deck object. Run: echo '{\"deck\":{...}}' | hl deck validate",
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
const dir = input.dir ? resolve(input.dir) : mkdtempSync(join(tmpdir(), 'hl-ix-'));
|
|
354
|
+
mkdirSync(dir, { recursive: true });
|
|
355
|
+
atomicWriteJson(deckPath(dir), deck);
|
|
356
|
+
const jobId = basename(dir);
|
|
357
|
+
const visuals = input.visuals !== false;
|
|
358
|
+
const useTmux = input.tmux !== false;
|
|
359
|
+
let sessionId;
|
|
360
|
+
if (visuals) {
|
|
361
|
+
if (input.sessionId && typeof input.sessionId === 'string') {
|
|
362
|
+
sessionId = input.sessionId;
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
// dynamic import to avoid pulling heavy dep into parse path
|
|
366
|
+
const { findRecentSessionId } = await import('./conversation/reader.js');
|
|
367
|
+
sessionId = findRecentSessionId(process.cwd()) || findRecentSessionId() || undefined;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
appendJobLog(dir, { level: 'info', event: 'job_started', message: 'deck ask job started', data: { jobId } });
|
|
371
|
+
if (INTERNAL_WRITE_TO) {
|
|
372
|
+
// Child mode: run ask() in-process, write result to INTERNAL_WRITE_TO
|
|
373
|
+
try {
|
|
374
|
+
const result = await ask(deck, { dir, sessionId });
|
|
375
|
+
appendJobLog(dir, { level: 'info', event: 'job_finished', message: 'deck resolved', data: { jobId } });
|
|
376
|
+
writeFileSync(INTERNAL_WRITE_TO, JSON.stringify(result) + '\n');
|
|
377
|
+
process.exit(0);
|
|
378
|
+
}
|
|
379
|
+
catch (askErr) {
|
|
380
|
+
appendJobLog(dir, {
|
|
381
|
+
level: 'error', event: 'job_failed',
|
|
382
|
+
message: askErr instanceof Error ? askErr.message : String(askErr),
|
|
383
|
+
});
|
|
384
|
+
process.exit(1);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (process.env['TMUX'] && useTmux) {
|
|
388
|
+
const scriptPath = process.argv[1];
|
|
389
|
+
if (!scriptPath) {
|
|
390
|
+
emitError({ error: 'internal', message: 'Cannot determine hl script path', next: 'Report this as a bug.' });
|
|
391
|
+
}
|
|
392
|
+
const sq = (s) => /^[a-zA-Z0-9_\-./:@%+=]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
|
|
393
|
+
const childInput = JSON.stringify({ deck, dir, sessionId, visuals, tmux: false });
|
|
394
|
+
const cmd = `echo ${sq(childInput)} | ${sq(process.execPath)} ${sq(scriptPath)} deck ask`;
|
|
395
|
+
try {
|
|
396
|
+
execFileSync('tmux', ['split-window', '-d', '-h', cmd], { stdio: 'ignore' });
|
|
397
|
+
}
|
|
398
|
+
catch (spawnErr) {
|
|
399
|
+
const msg = spawnErr instanceof Error ? spawnErr.message : String(spawnErr);
|
|
400
|
+
appendJobLog(dir, { level: 'error', event: 'job_failed', message: `tmux spawn failed: ${msg}` });
|
|
401
|
+
emitError({
|
|
402
|
+
error: 'internal',
|
|
403
|
+
message: `tmux spawn failed: ${msg}`,
|
|
404
|
+
next: 'Check that $TMUX is set. Or pass tmux:false.',
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
process.stdout.write(JSON.stringify({
|
|
408
|
+
job_id: jobId,
|
|
409
|
+
dir,
|
|
410
|
+
follow_up: `Call hl job result with stdin {"job_id":"${jobId}","wait":true} to block until the human finishes.`,
|
|
411
|
+
}) + '\n');
|
|
412
|
+
process.exit(0);
|
|
413
|
+
}
|
|
414
|
+
// Non-tmux path: ask() runs in-process (will block or fail without TTY — degraded).
|
|
415
|
+
appendJobLog(dir, { level: 'warn', event: 'job_started', message: 'no tmux; ask() runs in-process' });
|
|
416
|
+
try {
|
|
417
|
+
const result = await ask(deck, { dir, sessionId });
|
|
418
|
+
appendJobLog(dir, { level: 'info', event: 'job_finished', message: 'deck resolved', data: { jobId } });
|
|
419
|
+
writeFileSync(join(dir, 'response.json'), JSON.stringify({ responses: result.responses, completedAt: result.completedAt }, null, 2));
|
|
420
|
+
process.stdout.write(JSON.stringify({
|
|
421
|
+
job_id: jobId,
|
|
422
|
+
dir,
|
|
423
|
+
follow_up: `Call hl job result with stdin {"job_id":"${jobId}","wait":true} to retrieve the result.`,
|
|
424
|
+
_note: 'Non-tmux path: ask() blocked synchronously. Result is already available.',
|
|
425
|
+
}) + '\n');
|
|
426
|
+
process.exit(0);
|
|
427
|
+
}
|
|
428
|
+
catch (askErr) {
|
|
429
|
+
const msg = askErr instanceof Error ? askErr.message : String(askErr);
|
|
430
|
+
appendJobLog(dir, { level: 'error', event: 'job_failed', message: msg });
|
|
431
|
+
emitError({
|
|
432
|
+
error: 'internal',
|
|
433
|
+
message: `ask() failed: ${msg}`,
|
|
434
|
+
next: 'Set tmux:true (or run inside tmux) so the TUI can open an interactive pane.',
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
deckCmd
|
|
439
|
+
.command('validate')
|
|
440
|
+
.description('Preflight deck validation — no side effects.\n' +
|
|
441
|
+
'\n' +
|
|
442
|
+
'stdin { deck: object }\n' +
|
|
443
|
+
'stdout { ok: bool, errors: [{field, message}] }\n' +
|
|
444
|
+
'exit 0 if ok, 1 if invalid\n')
|
|
445
|
+
.helpOption('-h, --help', 'Show help')
|
|
446
|
+
.action(() => {
|
|
447
|
+
const input = parseStdinJson();
|
|
448
|
+
if (!input.deck) {
|
|
449
|
+
emitError({ error: 'bad_input', message: 'deck is required', field: 'deck', next: 'Provide: {"deck": {...}}' });
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
validateDeck(input.deck);
|
|
453
|
+
process.stdout.write(JSON.stringify({ ok: true, errors: [] }) + '\n');
|
|
454
|
+
process.exit(0);
|
|
455
|
+
}
|
|
456
|
+
catch (validationErr) {
|
|
457
|
+
const errors = parseValidationErrors(validationErr);
|
|
458
|
+
process.stdout.write(JSON.stringify({ ok: false, errors }) + '\n');
|
|
459
|
+
process.exit(1);
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
function parseValidationErrors(e) {
|
|
463
|
+
if (e && typeof e === 'object' && 'issues' in e) {
|
|
464
|
+
const issues = e.issues;
|
|
465
|
+
return issues.map((iss) => ({ field: iss.path.join('.'), message: iss.message }));
|
|
466
|
+
}
|
|
467
|
+
return [{ field: '', message: e instanceof Error ? e.message : String(e) }];
|
|
468
|
+
}
|
|
469
|
+
// ── review ────────────────────────────────────────────────────────────────────
|
|
470
|
+
const reviewCmd = program.command('review').description('Markdown document review with anchored comments.');
|
|
471
|
+
reviewCmd
|
|
472
|
+
.command('open')
|
|
473
|
+
.description('Kickoff: spawn the read-only editor review and return immediately.\n' +
|
|
474
|
+
'\n' +
|
|
475
|
+
'stdin { file: string (required, .md), output?: string|null,\n' +
|
|
476
|
+
' editor?: string|null, tmux?: bool=true }\n' +
|
|
477
|
+
'stdout { job_id: string, output: string (absolute), follow_up: string }\n' +
|
|
478
|
+
'\n' +
|
|
479
|
+
'Effects: spawns nvim/vim read-only; autosaves feedback JSON.\n')
|
|
480
|
+
.helpOption('-h, --help', 'Show help')
|
|
481
|
+
.action(async () => {
|
|
482
|
+
const input = parseStdinJson();
|
|
483
|
+
if (!input.file || typeof input.file !== 'string') {
|
|
484
|
+
emitError({ error: 'bad_input', message: 'file is required', field: 'file', next: 'Provide: {"file": "/path/to/doc.md"}' });
|
|
485
|
+
}
|
|
486
|
+
const absFile = resolve(input.file);
|
|
487
|
+
if (!existsSync(absFile)) {
|
|
488
|
+
emitError({ error: 'file_not_found', message: `File not found: ${absFile}`, field: 'file', next: 'Check the file path.' });
|
|
489
|
+
}
|
|
490
|
+
const output = resolve(input.output ? input.output : `${absFile}.feedback.json`);
|
|
491
|
+
const noTmux = input.tmux === false;
|
|
492
|
+
const jobDir = mkdtempSync(join(tmpdir(), 'hl-review-'));
|
|
493
|
+
const jobId = basename(jobDir);
|
|
494
|
+
appendJobLog(jobDir, { level: 'info', event: 'job_started', message: 'review open job started', data: { jobId, file: absFile } });
|
|
495
|
+
try {
|
|
496
|
+
const result = await launchReview(absFile, {
|
|
497
|
+
output,
|
|
498
|
+
editor: (input.editor && typeof input.editor === 'string') ? input.editor : undefined,
|
|
499
|
+
noTmux,
|
|
500
|
+
});
|
|
501
|
+
appendJobLog(jobDir, { level: 'info', event: 'job_finished', message: 'review finished', data: { comments: result.comments.length } });
|
|
502
|
+
writeFileSync(join(jobDir, 'feedback.json'), JSON.stringify(result, null, 2));
|
|
503
|
+
process.stdout.write(JSON.stringify({
|
|
504
|
+
job_id: jobId,
|
|
505
|
+
output,
|
|
506
|
+
follow_up: `Call hl job result with stdin {"job_id":"${jobId}","wait":true} to retrieve the feedback.`,
|
|
507
|
+
}) + '\n');
|
|
508
|
+
process.exit(0);
|
|
509
|
+
}
|
|
510
|
+
catch (reviewErr) {
|
|
511
|
+
const msg = reviewErr instanceof Error ? reviewErr.message : String(reviewErr);
|
|
512
|
+
appendJobLog(jobDir, { level: 'error', event: 'job_failed', message: msg });
|
|
513
|
+
if (msg.startsWith('Markdown file not found')) {
|
|
514
|
+
emitError({ error: 'file_not_found', message: msg, next: 'Check the file path.' });
|
|
515
|
+
}
|
|
516
|
+
if (msg.startsWith('Editor not found') || msg.startsWith('No editor found')) {
|
|
517
|
+
emitError({ error: 'editor_not_found', message: msg, next: 'Install Neovim (brew install neovim) or pass editor.' });
|
|
518
|
+
}
|
|
519
|
+
emitError({ error: 'internal', message: msg, next: 'Check stderr for details.' });
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
// ── view ──────────────────────────────────────────────────────────────────────
|
|
523
|
+
const viewCmd = program.command('view').description('Passive live render of a file in a tmux pane.');
|
|
524
|
+
viewCmd
|
|
525
|
+
.command('show')
|
|
526
|
+
.description('Render a file live in a tmux pane — passive, no result.\n' +
|
|
527
|
+
'\n' +
|
|
528
|
+
'stdin { path: string (required), watch?: bool=true, window?: "split"|"new"="split" }\n' +
|
|
529
|
+
'stdout { pane_id: string|null, reason: string|null }\n' +
|
|
530
|
+
'exit 0 always (not-in-tmux / renderer-unavailable is NOT an error)\n')
|
|
531
|
+
.helpOption('-h, --help', 'Show help')
|
|
532
|
+
.action(() => {
|
|
533
|
+
const input = parseStdinJson();
|
|
534
|
+
if (!input.path || typeof input.path !== 'string') {
|
|
535
|
+
emitError({ error: 'bad_input', message: 'path is required', field: 'path', next: 'Provide: {"path": "/abs/path/file.md"}' });
|
|
536
|
+
}
|
|
537
|
+
const absPath = resolve(input.path);
|
|
538
|
+
const watch = input.watch !== false;
|
|
539
|
+
const window = input.window === 'new' ? 'new' : 'split';
|
|
540
|
+
const res = display(absPath, { watch, window });
|
|
541
|
+
if (res.paneId) {
|
|
542
|
+
process.stdout.write(JSON.stringify({ pane_id: res.paneId, reason: null }) + '\n');
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
process.stdout.write(JSON.stringify({ pane_id: null, reason: 'Not in tmux or termrender unavailable.' }) + '\n');
|
|
546
|
+
}
|
|
547
|
+
process.exit(0);
|
|
548
|
+
});
|
|
549
|
+
// ── inbox ─────────────────────────────────────────────────────────────────────
|
|
550
|
+
const inboxCmd = program.command('inbox').description('Browse and resolve pending interactions across root dirs.');
|
|
551
|
+
inboxCmd
|
|
552
|
+
.command('list')
|
|
553
|
+
.description('Read-only paginated query of pending interactions.\n' +
|
|
554
|
+
'\n' +
|
|
555
|
+
'stdin { roots: string[] (required, ≥1), limit?: int=20 (max 100), cursor?: string|null }\n' +
|
|
556
|
+
'stdout { items: [{dir,title,askedBy,blockedSince,interactionCount}],\n' +
|
|
557
|
+
' next_cursor: string|null, total: int|null }\n' +
|
|
558
|
+
'Sorted by blockedSince ascending.\n')
|
|
559
|
+
.helpOption('-h, --help', 'Show help')
|
|
560
|
+
.action(() => {
|
|
561
|
+
const input = parseStdinJson();
|
|
562
|
+
if (!Array.isArray(input.roots) || input.roots.length === 0) {
|
|
563
|
+
emitError({ error: 'bad_input', message: 'roots must be a non-empty array', field: 'roots', next: 'Provide: {"roots": ["/path/to/inbox"]}' });
|
|
564
|
+
}
|
|
565
|
+
const roots = input.roots.map((r) => resolve(r));
|
|
566
|
+
const limit = Math.min(typeof input.limit === 'number' ? input.limit : 20, 100);
|
|
567
|
+
const allItems = scanInbox(roots);
|
|
568
|
+
const total = allItems.length;
|
|
569
|
+
let startIdx = 0;
|
|
570
|
+
if (input.cursor) {
|
|
571
|
+
const idx = allItems.findIndex((it) => it.dir === input.cursor);
|
|
572
|
+
startIdx = idx >= 0 ? idx : 0;
|
|
573
|
+
}
|
|
574
|
+
const page = allItems.slice(startIdx, startIdx + limit);
|
|
575
|
+
const nextCursor = startIdx + limit < total ? allItems[startIdx + limit]?.dir : null;
|
|
576
|
+
const items = page.map((it) => {
|
|
577
|
+
const dk = readJson(deckPath(it.dir));
|
|
578
|
+
return {
|
|
579
|
+
dir: it.dir,
|
|
580
|
+
title: it.title,
|
|
581
|
+
askedBy: it.source?.askedBy,
|
|
582
|
+
blockedSince: it.blockedSince,
|
|
583
|
+
interactionCount: dk ? dk.interactions.length : 1,
|
|
584
|
+
};
|
|
585
|
+
});
|
|
586
|
+
process.stdout.write(JSON.stringify({
|
|
587
|
+
items,
|
|
588
|
+
next_cursor: nextCursor !== undefined ? nextCursor : null,
|
|
589
|
+
total,
|
|
590
|
+
}) + '\n');
|
|
591
|
+
process.exit(0);
|
|
592
|
+
});
|
|
593
|
+
inboxCmd
|
|
594
|
+
.command('resolve')
|
|
595
|
+
.description('Kickoff: spawn the inbox-walker TUI detached.\n' +
|
|
596
|
+
'\n' +
|
|
597
|
+
'stdin { roots: string[] (required) }\n' +
|
|
598
|
+
'stdout { job_id: string, follow_up: string }\n')
|
|
599
|
+
.helpOption('-h, --help', 'Show help')
|
|
600
|
+
.action(async () => {
|
|
601
|
+
const input = parseStdinJson();
|
|
602
|
+
if (!Array.isArray(input.roots) || input.roots.length === 0) {
|
|
603
|
+
emitError({ error: 'bad_input', message: 'roots must be a non-empty array', field: 'roots', next: 'Provide: {"roots": ["/path/to/inbox"]}' });
|
|
604
|
+
}
|
|
605
|
+
const roots = input.roots.map((r) => resolve(r));
|
|
606
|
+
const jobDir = mkdtempSync(join(tmpdir(), 'hl-inbox-'));
|
|
607
|
+
const jobId = basename(jobDir);
|
|
608
|
+
appendJobLog(jobDir, { level: 'info', event: 'job_started', message: 'inbox resolve job started', data: { jobId, roots } });
|
|
609
|
+
try {
|
|
610
|
+
await inbox(roots);
|
|
611
|
+
appendJobLog(jobDir, { level: 'info', event: 'job_finished', message: 'inbox resolved', data: { jobId } });
|
|
612
|
+
process.stdout.write(JSON.stringify({
|
|
613
|
+
job_id: jobId,
|
|
614
|
+
follow_up: `Inbox walk complete. Call hl job result with stdin {"job_id":"${jobId}"} for summary.`,
|
|
615
|
+
}) + '\n');
|
|
616
|
+
process.exit(0);
|
|
617
|
+
}
|
|
618
|
+
catch (inboxErr) {
|
|
619
|
+
const msg = inboxErr instanceof Error ? inboxErr.message : String(inboxErr);
|
|
620
|
+
appendJobLog(jobDir, { level: 'error', event: 'job_failed', message: msg });
|
|
621
|
+
emitError({ error: 'internal', message: msg, next: 'Ensure the roots are valid directories.' });
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
// ── job ───────────────────────────────────────────────────────────────────────
|
|
625
|
+
const jobCmd = program.command('job').description('Inspect, wait on, or cancel running jobs.');
|
|
626
|
+
jobCmd
|
|
627
|
+
.command('status')
|
|
628
|
+
.description('Read-only job state snapshot.\n' +
|
|
447
629
|
'\n' +
|
|
448
|
-
'
|
|
449
|
-
'
|
|
450
|
-
'
|
|
451
|
-
.
|
|
452
|
-
|
|
453
|
-
|
|
630
|
+
'stdin { job_id: string }\n' +
|
|
631
|
+
'stdout { state: "live"|"done"|"failed"|"canceled", kind: "deck"|"review"|"inbox",\n' +
|
|
632
|
+
' age_seconds: number, last_event: {ts,event,message}|null }\n')
|
|
633
|
+
.helpOption('-h, --help', 'Show help')
|
|
634
|
+
.action(() => {
|
|
635
|
+
const input = parseStdinJson();
|
|
636
|
+
if (!input.job_id || typeof input.job_id !== 'string') {
|
|
637
|
+
emitError({ error: 'bad_input', message: 'job_id is required', field: 'job_id', next: 'Provide: {"job_id": "<id>"}' });
|
|
638
|
+
}
|
|
639
|
+
const dir = resolveJobDir(input.job_id);
|
|
640
|
+
if (!existsSync(dir)) {
|
|
641
|
+
emitError({ error: 'job_not_found', message: `Job not found: ${input.job_id}`, next: 'Check the job_id.' });
|
|
642
|
+
}
|
|
643
|
+
let ageSecs = 0;
|
|
644
|
+
try {
|
|
645
|
+
const _st = {};
|
|
646
|
+
void _st;
|
|
647
|
+
const st = statSync(dir);
|
|
648
|
+
ageSecs = Math.round((Date.now() - st.birthtimeMs) / 1000);
|
|
649
|
+
}
|
|
650
|
+
catch (statErr) {
|
|
651
|
+
void String(statErr); // stat unavailable — report 0
|
|
652
|
+
}
|
|
653
|
+
const state = detectJobState(dir);
|
|
654
|
+
const kind = detectJobKind(dir);
|
|
655
|
+
const last = lastLogEvent(dir);
|
|
656
|
+
process.stdout.write(JSON.stringify({
|
|
657
|
+
state,
|
|
658
|
+
kind,
|
|
659
|
+
age_seconds: ageSecs,
|
|
660
|
+
last_event: last ? { ts: last.ts, event: last.event, message: last.message } : null,
|
|
661
|
+
}) + '\n');
|
|
662
|
+
process.exit(0);
|
|
663
|
+
});
|
|
664
|
+
function tryReadJobResult(dir, kind) {
|
|
665
|
+
if (kind === 'deck') {
|
|
666
|
+
const rp = responsePath(dir);
|
|
667
|
+
if (!existsSync(rp))
|
|
668
|
+
return null;
|
|
669
|
+
const raw = tryParseJson(readFileSync(rp, 'utf8'));
|
|
670
|
+
if (!raw)
|
|
671
|
+
return null;
|
|
672
|
+
const dk = readJson(deckPath(dir));
|
|
673
|
+
return {
|
|
674
|
+
summary: '',
|
|
675
|
+
responsePath: rp,
|
|
676
|
+
schema: 'humanloop.response/v2',
|
|
677
|
+
responses: raw.responses,
|
|
678
|
+
completedAt: raw.completedAt,
|
|
679
|
+
_note: dk ? `Deck had ${dk.interactions.length} interaction(s)` : undefined,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
if (kind === 'review') {
|
|
683
|
+
const fp = join(dir, 'feedback.json');
|
|
684
|
+
if (!existsSync(fp))
|
|
685
|
+
return null;
|
|
686
|
+
return tryParseJson(readFileSync(fp, 'utf8'));
|
|
687
|
+
}
|
|
688
|
+
// inbox
|
|
689
|
+
const logPath = jobLogPath(dir);
|
|
690
|
+
if (!existsSync(logPath))
|
|
691
|
+
return null;
|
|
692
|
+
if (detectJobState(dir) !== 'done')
|
|
693
|
+
return null;
|
|
694
|
+
const lines = readLogLines(logPath);
|
|
695
|
+
let resolved = 0;
|
|
696
|
+
for (const line of lines) {
|
|
697
|
+
const entry = tryParseJson(line);
|
|
698
|
+
if (entry && entry.event === 'inbox_resolved')
|
|
699
|
+
resolved++;
|
|
700
|
+
}
|
|
701
|
+
return { resolved };
|
|
702
|
+
}
|
|
703
|
+
jobCmd
|
|
704
|
+
.command('result')
|
|
705
|
+
.description('Retrieve terminal payload of a finished job.\n' +
|
|
706
|
+
'\n' +
|
|
707
|
+
'stdin { job_id: string, wait?: bool=false }\n' +
|
|
708
|
+
'stdout deck → ResolutionEnvelope (humanloop.response/v2)\n' +
|
|
709
|
+
' review → FeedbackResult\n' +
|
|
710
|
+
' inbox → { resolved: int }\n' +
|
|
711
|
+
' not done + wait:false → { error:"not_ready", ... } exit 1\n' +
|
|
712
|
+
' wait:true blocks until sidecar appears or job terminates.\n')
|
|
713
|
+
.helpOption('-h, --help', 'Show help')
|
|
714
|
+
.action(async () => {
|
|
715
|
+
const input = parseStdinJson();
|
|
716
|
+
if (!input.job_id || typeof input.job_id !== 'string') {
|
|
717
|
+
emitError({ error: 'bad_input', message: 'job_id is required', field: 'job_id', next: 'Provide: {"job_id": "<id>", "wait": true}' });
|
|
718
|
+
}
|
|
719
|
+
const dir = resolveJobDir(input.job_id);
|
|
720
|
+
if (!existsSync(dir)) {
|
|
721
|
+
emitError({ error: 'job_not_found', message: `Job not found: ${input.job_id}`, next: 'Check the job_id.' });
|
|
722
|
+
}
|
|
723
|
+
const wait = input.wait === true;
|
|
724
|
+
const kind = detectJobKind(dir);
|
|
725
|
+
if (!wait) {
|
|
726
|
+
const result = tryReadJobResult(dir, kind);
|
|
727
|
+
if (result === null) {
|
|
728
|
+
emitError({ error: 'not_ready', message: 'Job is not yet complete.', next: 'Retry with wait:true to block until done.' }, 1);
|
|
729
|
+
}
|
|
730
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
731
|
+
process.exit(0);
|
|
732
|
+
}
|
|
733
|
+
await new Promise((resolvePromise) => {
|
|
734
|
+
const poll = setInterval(() => {
|
|
735
|
+
const result = tryReadJobResult(dir, kind);
|
|
736
|
+
if (result !== null) {
|
|
737
|
+
clearInterval(poll);
|
|
738
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
739
|
+
process.exit(0);
|
|
740
|
+
}
|
|
741
|
+
const state = detectJobState(dir);
|
|
742
|
+
if (state === 'failed' || state === 'canceled') {
|
|
743
|
+
clearInterval(poll);
|
|
744
|
+
emitError({ error: 'not_ready', message: `Job ended with state: ${state}`, next: 'Check hl job logs for details.' }, 1);
|
|
745
|
+
}
|
|
746
|
+
}, 200);
|
|
747
|
+
void resolvePromise;
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
jobCmd
|
|
751
|
+
.command('logs')
|
|
752
|
+
.description('Stream job.log events (JSONL).\n' +
|
|
753
|
+
'\n' +
|
|
754
|
+
'stdin { job_id: string, since?: string|null,\n' +
|
|
755
|
+
' level?: "debug"|"info"|"warn"|"error"="info", follow?: bool=false }\n' +
|
|
756
|
+
'stdout JSONL — one event per line: { ts, level, event, message, data? }\n' +
|
|
757
|
+
'follow:false → emit historical then exit; follow:true → stream until done.\n')
|
|
758
|
+
.helpOption('-h, --help', 'Show help')
|
|
759
|
+
.action(async () => {
|
|
760
|
+
const input = parseStdinJson();
|
|
761
|
+
if (!input.job_id || typeof input.job_id !== 'string') {
|
|
762
|
+
emitError({ error: 'bad_input', message: 'job_id is required', field: 'job_id', next: 'Provide: {"job_id": "<id>"}' });
|
|
763
|
+
}
|
|
764
|
+
const dir = resolveJobDir(input.job_id);
|
|
765
|
+
if (!existsSync(dir)) {
|
|
766
|
+
emitError({ error: 'job_not_found', message: `Job not found: ${input.job_id}`, next: 'Check the job_id.' });
|
|
767
|
+
}
|
|
768
|
+
const levelOrder = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
769
|
+
const inputLevel = (input.level && input.level in levelOrder) ? input.level : 'info';
|
|
770
|
+
const minLevel = levelOrder[inputLevel];
|
|
771
|
+
const since = input.since;
|
|
772
|
+
const follow = input.follow === true;
|
|
773
|
+
const logPath = jobLogPath(dir);
|
|
774
|
+
let emittedCount = 0;
|
|
775
|
+
function emitLogLines() {
|
|
776
|
+
const lines = readLogLines(logPath);
|
|
777
|
+
for (let i = emittedCount; i < lines.length; i++) {
|
|
778
|
+
const entry = tryParseJson(lines[i]);
|
|
779
|
+
if (!entry)
|
|
780
|
+
continue;
|
|
781
|
+
if (since && entry.ts <= since)
|
|
782
|
+
continue;
|
|
783
|
+
const entryLevel = entry.level in levelOrder ? levelOrder[entry.level] : 0;
|
|
784
|
+
if (entryLevel < minLevel)
|
|
785
|
+
continue;
|
|
786
|
+
process.stdout.write(JSON.stringify(entry) + '\n');
|
|
787
|
+
}
|
|
788
|
+
emittedCount = lines.length;
|
|
789
|
+
}
|
|
790
|
+
emitLogLines();
|
|
791
|
+
if (!follow) {
|
|
792
|
+
process.exit(0);
|
|
793
|
+
}
|
|
794
|
+
await new Promise((resolvePromise) => {
|
|
795
|
+
const poll = setInterval(() => {
|
|
796
|
+
emitLogLines();
|
|
797
|
+
const state = detectJobState(dir);
|
|
798
|
+
if (state === 'done' || state === 'failed' || state === 'canceled') {
|
|
799
|
+
clearInterval(poll);
|
|
800
|
+
process.exit(0);
|
|
801
|
+
}
|
|
802
|
+
}, 200);
|
|
803
|
+
void resolvePromise;
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
jobCmd
|
|
807
|
+
.command('cancel')
|
|
808
|
+
.description('Best-effort cancel: signal the job pane and close it if possible.\n' +
|
|
809
|
+
'\n' +
|
|
810
|
+
'stdin { job_id: string }\n' +
|
|
811
|
+
'stdout { canceled: bool, message: string }\n')
|
|
812
|
+
.helpOption('-h, --help', 'Show help')
|
|
813
|
+
.action(() => {
|
|
814
|
+
const input = parseStdinJson();
|
|
815
|
+
if (!input.job_id || typeof input.job_id !== 'string') {
|
|
816
|
+
emitError({ error: 'bad_input', message: 'job_id is required', field: 'job_id', next: 'Provide: {"job_id": "<id>"}' });
|
|
817
|
+
}
|
|
818
|
+
const dir = resolveJobDir(input.job_id);
|
|
819
|
+
if (!existsSync(dir)) {
|
|
820
|
+
emitError({ error: 'job_not_found', message: `Job not found: ${input.job_id}`, next: 'Check the job_id.' });
|
|
821
|
+
}
|
|
822
|
+
let canceled = false;
|
|
823
|
+
let message = 'No tmux pane found; signal not delivered (job may already be done).';
|
|
824
|
+
if (process.env['TMUX']) {
|
|
825
|
+
try {
|
|
826
|
+
const panes = execFileSync('tmux', ['list-panes', '-a', '-F', '#{pane_id} #{pane_current_command}'], { encoding: 'utf8' });
|
|
827
|
+
for (const line of panes.split('\n')) {
|
|
828
|
+
const paneId = line.split(' ')[0];
|
|
829
|
+
if (!paneId)
|
|
830
|
+
continue;
|
|
831
|
+
try {
|
|
832
|
+
execFileSync('tmux', ['send-keys', '-t', paneId, 'q', ''], { stdio: 'ignore' });
|
|
833
|
+
}
|
|
834
|
+
catch (sendErr) {
|
|
835
|
+
void String(sendErr); // best-effort per pane
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
canceled = true;
|
|
839
|
+
message = 'Signal delivered to tmux pane(s).';
|
|
840
|
+
}
|
|
841
|
+
catch (tmuxErr) {
|
|
842
|
+
message = `tmux pane lookup failed: ${tmuxErr instanceof Error ? tmuxErr.message : String(tmuxErr)}`;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
appendJobLog(dir, { level: 'info', event: 'job_canceled', message: `cancel requested: ${message}` });
|
|
846
|
+
process.stdout.write(JSON.stringify({ canceled, message }) + '\n');
|
|
847
|
+
process.exit(0);
|
|
848
|
+
});
|
|
849
|
+
// ── schema ────────────────────────────────────────────────────────────────────
|
|
850
|
+
const schemaCmd = program.command('schema').description('Print JSON Schemas for hl data types.');
|
|
851
|
+
schemaCmd
|
|
852
|
+
.command('show')
|
|
853
|
+
.description('Print the JSON Schema for a data kind.\n' +
|
|
854
|
+
'\n' +
|
|
855
|
+
'stdin { kind?: "deck"|"resolution"|"feedback"="deck" }\n' +
|
|
856
|
+
'stdout the JSON Schema object\n')
|
|
857
|
+
.helpOption('-h, --help', 'Show help')
|
|
858
|
+
.action(() => {
|
|
859
|
+
const input = parseStdinJson();
|
|
860
|
+
const kind = (typeof input.kind === 'string' && input.kind) ? input.kind : 'deck';
|
|
861
|
+
if (kind === 'resolution') {
|
|
862
|
+
process.stdout.write(JSON.stringify(RESPONSE_SCHEMA, null, 2) + '\n');
|
|
863
|
+
}
|
|
864
|
+
else if (kind === 'feedback') {
|
|
865
|
+
process.stdout.write(JSON.stringify(FEEDBACK_SCHEMA, null, 2) + '\n');
|
|
866
|
+
}
|
|
867
|
+
else if (kind === 'deck') {
|
|
868
|
+
process.stdout.write(JSON.stringify(REQUEST_SCHEMA, null, 2) + '\n');
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
emitError({
|
|
872
|
+
error: 'bad_input',
|
|
873
|
+
message: `Unknown kind: ${kind}. Valid: deck, resolution, feedback`,
|
|
874
|
+
field: 'kind',
|
|
875
|
+
next: 'Provide: {"kind": "deck"} or {"kind": "resolution"} or {"kind": "feedback"}',
|
|
876
|
+
});
|
|
877
|
+
}
|
|
454
878
|
});
|
|
455
879
|
program.parse();
|