@crouton-kit/humanloop 0.3.33 → 0.3.34
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.d.ts +4 -8
- package/dist/api.js +6 -29
- package/dist/browser/server.d.ts +3 -3
- package/dist/browser/server.js +22 -25
- package/dist/cli.js +126 -1114
- package/dist/editor/feedback.d.ts +9 -0
- package/dist/editor/feedback.js +23 -0
- package/dist/editor/review.d.ts +8 -30
- package/dist/editor/review.js +79 -118
- package/dist/inbox/claim.d.ts +23 -0
- package/dist/inbox/claim.js +67 -0
- package/dist/inbox/completion.d.ts +4 -0
- package/dist/inbox/completion.js +100 -0
- package/dist/inbox/controller.d.ts +57 -0
- package/dist/inbox/controller.js +311 -0
- package/dist/inbox/convention.d.ts +15 -10
- package/dist/inbox/convention.js +149 -79
- package/dist/inbox/deck-adapter.d.ts +27 -0
- package/dist/inbox/deck-adapter.js +57 -0
- package/dist/inbox/deck-schema.d.ts +18 -14
- package/dist/inbox/deck-schema.js +59 -117
- package/dist/inbox/layout.d.ts +8 -0
- package/dist/inbox/layout.js +9 -0
- package/dist/inbox/registry.d.ts +29 -0
- package/dist/inbox/registry.js +97 -0
- package/dist/inbox/review-adapter.d.ts +22 -0
- package/dist/inbox/review-adapter.js +56 -0
- package/dist/inbox/scan.d.ts +3 -2
- package/dist/inbox/scan.js +71 -43
- package/dist/inbox/tickets.d.ts +63 -0
- package/dist/inbox/tickets.js +247 -0
- package/dist/inbox/tui.d.ts +3 -6
- package/dist/inbox/tui.js +24 -112
- package/dist/index.d.ts +12 -3
- package/dist/index.js +8 -2
- package/dist/summary.d.ts +2 -3
- package/dist/summary.js +2 -3
- package/dist/surfaces/inbox-popup.d.ts +2 -0
- package/dist/surfaces/inbox-popup.js +32 -0
- package/dist/tui/app.js +13 -0
- package/dist/tui/tmux.d.ts +30 -7
- package/dist/tui/tmux.js +179 -66
- package/dist/types.d.ts +68 -7
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -1,1146 +1,158 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return entry;
|
|
182
|
-
}
|
|
183
|
-
return null;
|
|
37
|
+
function roots(values) { return values?.map((root) => resolve(root)); }
|
|
38
|
+
/** Resolve an explicit --root to its existing registration; an unregistered root is an error, never a silent humanloop-owned registration. */
|
|
39
|
+
function requireRegisteredRoot(root) {
|
|
40
|
+
const abs = resolve(root);
|
|
41
|
+
const registration = registeredInboxRoot(abs);
|
|
42
|
+
if (registration === null)
|
|
43
|
+
throw new Error(`root ${abs} is not registered; run \`hl inbox roots register --root ${abs} --owner <owner>\` first`);
|
|
44
|
+
return registration;
|
|
184
45
|
}
|
|
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
46
|
const program = new Command();
|
|
293
|
-
program
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
}
|
|
47
|
+
program.name('hl').description('Humanloop durable inbox and review surface.').helpOption('-h, --help');
|
|
48
|
+
const inbox = program.command('inbox').description('Open, inspect, and configure the centralized human inbox.');
|
|
49
|
+
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) => {
|
|
50
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
51
|
+
fail('hl inbox open requires an interactive TTY; use hl inbox list for read-only output');
|
|
52
|
+
await openInboxPopup(options.controlSocket, roots(options.root));
|
|
473
53
|
});
|
|
474
|
-
|
|
475
|
-
.
|
|
476
|
-
|
|
477
|
-
'
|
|
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);
|
|
54
|
+
inbox.command('toggle').description('Toggle the inbox popup for one tmux client.').option('--tmux-socket <path>').option('--tmux-client <name>').option('--target-pane <pane>').action(async (options) => {
|
|
55
|
+
const result = await toggleInboxPopup({ socket: options.tmuxSocket, client: options.tmuxClient, targetPane: options.targetPane });
|
|
56
|
+
emit({ result, next: result === 'not_in_tmux' ? 'Run hl inbox open in a human terminal.' : undefined });
|
|
57
|
+
process.exit(result === 'failed' || result === 'ambiguous_client' ? 1 : 0);
|
|
552
58
|
});
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
}
|
|
59
|
+
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))));
|
|
60
|
+
const root = inbox.command('roots').description('Manage durable interaction roots.');
|
|
61
|
+
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) => {
|
|
62
|
+
if ((options.handlerCommand === undefined) !== (options.handlerArg.length === 0))
|
|
63
|
+
fail('a completion handler requires both --handler-command and at least one --handler-arg');
|
|
64
|
+
emit(registerInboxRoot({ root: resolve(options.root), owner: options.owner, ...(options.handlerCommand === undefined ? {} : { handler: { command: options.handlerCommand, args: options.handlerArg } }) }));
|
|
576
65
|
});
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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.
|
|
66
|
+
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) }));
|
|
67
|
+
root.command('list').description('List registered roots and availability.').action(() => emit(listInboxRoots()));
|
|
68
|
+
inbox.command('bind').description('Install a collision-safe tmux inbox toggle binding.').option('--key <tmux-key>').action((options) => emit(installInboxBinding({ key: options.key })));
|
|
69
|
+
inbox.command('unbind').description('Remove the configured inbox binding only when it is humanloop-owned.').action(() => emit(unbindInboxBinding()));
|
|
70
|
+
inbox.command('binding').description('Inspect the configured inbox binding.').action(() => emit(inspectInboxBinding()));
|
|
71
|
+
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
72
|
try {
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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.' });
|
|
73
|
+
const body = objectInput();
|
|
74
|
+
const deck = validateDeck(body.deck);
|
|
75
|
+
if (options.inline) {
|
|
76
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
77
|
+
throw new Error('hl deck ask --inline requires an interactive TTY; omit --inline to submit a durable ticket');
|
|
78
|
+
emit(await ask(deck));
|
|
79
|
+
return;
|
|
701
80
|
}
|
|
702
|
-
|
|
703
|
-
|
|
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"]}' });
|
|
81
|
+
const registration = options.root === undefined ? managedInboxRoot() : requireRegisteredRoot(options.root);
|
|
82
|
+
emit({ ...submitDeck({ root: registration.root, id: typeof body.id === 'string' ? body.id : randomUUID(), deck }), queued: true });
|
|
824
83
|
}
|
|
825
|
-
|
|
826
|
-
|
|
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;
|
|
84
|
+
catch (error) {
|
|
85
|
+
fail(error);
|
|
833
86
|
}
|
|
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
87
|
});
|
|
853
|
-
|
|
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 } });
|
|
88
|
+
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
89
|
try {
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
90
|
+
const body = objectInput();
|
|
91
|
+
if (typeof body.file !== 'string' || !existsSync(resolve(body.file)))
|
|
92
|
+
throw new Error('file must be an existing markdown path');
|
|
93
|
+
const absFile = resolve(body.file);
|
|
94
|
+
const output = typeof body.output === 'string' ? resolve(body.output) : `${absFile}.feedback.json`;
|
|
95
|
+
if (options.inline) {
|
|
96
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
97
|
+
throw new Error('hl review open --inline requires an interactive TTY; omit --inline to submit a durable ticket');
|
|
98
|
+
emit(await launchReview(absFile, { output, onPropose: (result) => { writeFeedbackResult(output, result); } }));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (typeof body.title !== 'string' || !body.title)
|
|
102
|
+
throw new Error('title is required');
|
|
103
|
+
const registration = options.root === undefined ? managedInboxRoot() : requireRegisteredRoot(options.root);
|
|
104
|
+
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 });
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
fail(error);
|
|
882
108
|
}
|
|
883
109
|
});
|
|
884
|
-
|
|
885
|
-
|
|
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;
|
|
110
|
+
const view = program.command('view').description('Passively display files; never a ticket surface.');
|
|
111
|
+
view.command('show').description('Live-render a file in a tmux pane. Passive, no result.').action(() => {
|
|
904
112
|
try {
|
|
905
|
-
const
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
113
|
+
const body = objectInput();
|
|
114
|
+
if (typeof body.path !== 'string' || !body.path)
|
|
115
|
+
throw new Error('path is required');
|
|
116
|
+
const result = display(resolve(body.path), { window: body.window === 'new' ? 'new' : 'split' });
|
|
117
|
+
emit({ pane_id: result.paneId ?? null, reason: result.paneId === undefined ? 'Not in tmux or termrender unavailable.' : null });
|
|
909
118
|
}
|
|
910
|
-
catch (
|
|
911
|
-
|
|
119
|
+
catch (error) {
|
|
120
|
+
fail(error);
|
|
912
121
|
}
|
|
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
122
|
});
|
|
924
|
-
function
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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 };
|
|
123
|
+
function docSource(body) {
|
|
124
|
+
const hasSource = typeof body.source === 'string' && body.source.length > 0;
|
|
125
|
+
const hasPath = typeof body.path === 'string' && body.path.length > 0;
|
|
126
|
+
if (hasSource === hasPath)
|
|
127
|
+
throw new Error('provide exactly one of {source, path}');
|
|
128
|
+
if (hasSource)
|
|
129
|
+
return body.source;
|
|
130
|
+
const abs = resolve(body.path);
|
|
131
|
+
if (!existsSync(abs))
|
|
132
|
+
throw new Error(`path not found: ${abs}`);
|
|
133
|
+
return readFileSync(abs, 'utf8');
|
|
969
134
|
}
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
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.' });
|
|
135
|
+
const doc = program.command('doc').description('Render or validate directive-flavored markdown to stdout via the managed termrender binary.');
|
|
136
|
+
doc.command('check').description('Validate directive-flavored markdown without rendering.').action(() => {
|
|
137
|
+
try {
|
|
138
|
+
emit(checkMarkdown(docSource(objectInput())));
|
|
1088
139
|
}
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|
-
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
fail(error);
|
|
1111
142
|
}
|
|
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
143
|
});
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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');
|
|
144
|
+
doc.command('render').description('Render directive-flavored markdown to ANSI or plain text on stdout.').action(() => {
|
|
145
|
+
try {
|
|
146
|
+
const body = objectInput();
|
|
147
|
+
const source = docSource(body);
|
|
148
|
+
const width = typeof body.width === 'number' && body.width > 0 ? body.width : (process.stdout.columns || 100);
|
|
149
|
+
let out = renderMarkdown(source, width).join('\n');
|
|
150
|
+
if (body.color === false)
|
|
151
|
+
out = out.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
|
|
152
|
+
process.stdout.write(out.endsWith('\n') ? out : `${out}\n`);
|
|
1136
153
|
}
|
|
1137
|
-
|
|
1138
|
-
|
|
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
|
-
});
|
|
154
|
+
catch (error) {
|
|
155
|
+
fail(error);
|
|
1144
156
|
}
|
|
1145
157
|
});
|
|
1146
|
-
program.
|
|
158
|
+
program.parseAsync().catch(fail);
|