@crouton-kit/humanloop 0.3.33 → 0.3.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/api.d.ts +4 -8
  2. package/dist/api.js +6 -29
  3. package/dist/browser/server.d.ts +3 -3
  4. package/dist/browser/server.js +22 -25
  5. package/dist/cli.js +131 -1114
  6. package/dist/editor/feedback.d.ts +9 -0
  7. package/dist/editor/feedback.js +23 -0
  8. package/dist/editor/review.d.ts +12 -30
  9. package/dist/editor/review.js +109 -119
  10. package/dist/inbox/claim.d.ts +23 -0
  11. package/dist/inbox/claim.js +67 -0
  12. package/dist/inbox/completion.d.ts +4 -0
  13. package/dist/inbox/completion.js +107 -0
  14. package/dist/inbox/controller.d.ts +74 -0
  15. package/dist/inbox/controller.js +457 -0
  16. package/dist/inbox/convention.d.ts +15 -10
  17. package/dist/inbox/convention.js +149 -79
  18. package/dist/inbox/deck-adapter.d.ts +27 -0
  19. package/dist/inbox/deck-adapter.js +57 -0
  20. package/dist/inbox/deck-schema.d.ts +18 -14
  21. package/dist/inbox/deck-schema.js +59 -117
  22. package/dist/inbox/layout.d.ts +8 -0
  23. package/dist/inbox/layout.js +9 -0
  24. package/dist/inbox/registry.d.ts +29 -0
  25. package/dist/inbox/registry.js +97 -0
  26. package/dist/inbox/review-adapter.d.ts +24 -0
  27. package/dist/inbox/review-adapter.js +57 -0
  28. package/dist/inbox/scan.d.ts +3 -2
  29. package/dist/inbox/scan.js +71 -43
  30. package/dist/inbox/tickets.d.ts +63 -0
  31. package/dist/inbox/tickets.js +247 -0
  32. package/dist/inbox/tui.d.ts +3 -6
  33. package/dist/inbox/tui.js +24 -112
  34. package/dist/index.d.ts +12 -3
  35. package/dist/index.js +8 -2
  36. package/dist/render/termrender.d.ts +5 -0
  37. package/dist/render/termrender.js +63 -4
  38. package/dist/summary.d.ts +2 -3
  39. package/dist/summary.js +2 -3
  40. package/dist/surfaces/inbox-popup.d.ts +2 -0
  41. package/dist/surfaces/inbox-popup.js +32 -0
  42. package/dist/tui/app.js +13 -0
  43. package/dist/tui/tmux.d.ts +30 -7
  44. package/dist/tui/tmux.js +190 -66
  45. package/dist/types.d.ts +68 -7
  46. package/package.json +2 -2
@@ -0,0 +1,107 @@
1
+ import { readdirSync, statSync, unlinkSync } from 'node:fs';
2
+ import { spawn } from 'node:child_process';
3
+ import { basename, resolve } from 'node:path';
4
+ import { atomicWriteJson, deliveryErrorPath, deliveryPath, responsePath, readJson, reviewPath, withExclusiveDirectoryLockAsync } from './convention.js';
5
+ import { registeredInboxRoot } from './registry.js';
6
+ import { readTicketResult, ticketRoot } from './tickets.js';
7
+ import { validateReviewProjection } from './deck-schema.js';
8
+ function receiptMatches(dir) {
9
+ const receipt = readJson(deliveryPath(dir));
10
+ return receipt?.schema === 'humanloop.delivery/v1' && receipt.responsePath === responsePath(dir);
11
+ }
12
+ function eventFor(root, dir, result) {
13
+ return { schema: 'humanloop.completion/v1', root, dir, ticketId: basename(dir), kind: result.kind, outcome: result.kind === 'canceled' ? 'canceled' : 'resolved', responsePath: responsePath(dir) };
14
+ }
15
+ async function runHandler(command, args, event) {
16
+ await new Promise((resolvePromise, rejectPromise) => {
17
+ // stdout stays ignored (a handler acknowledges by exit code, never stdout),
18
+ // but stderr is captured so a nonzero exit surfaces the handler's own
19
+ // diagnostics in the delivery-error record instead of a bare exit code.
20
+ const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'pipe'] });
21
+ let stderr = '';
22
+ child.stderr?.on('data', (chunk) => { stderr += chunk; if (stderr.length > 8192)
23
+ stderr = stderr.slice(-8192); });
24
+ let timedOut = false;
25
+ const timeout = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 30_000);
26
+ child.once('error', (error) => { clearTimeout(timeout); rejectPromise(error); });
27
+ child.once('exit', (code, signal) => {
28
+ clearTimeout(timeout);
29
+ const detail = stderr.trim() === '' ? '' : `: ${stderr.trim()}`;
30
+ if (timedOut)
31
+ rejectPromise(new Error(`completion handler timed out after 30 seconds${detail}`));
32
+ else if (code === 0)
33
+ resolvePromise();
34
+ else
35
+ rejectPromise(new Error(`completion handler failed (${signal ?? code ?? 'unknown'})${detail}`));
36
+ });
37
+ child.stdin.end(`${JSON.stringify(event)}\n`);
38
+ });
39
+ }
40
+ function projectReview(dir, result) {
41
+ if (result.kind !== 'review')
42
+ return;
43
+ const descriptor = validateReviewProjection(dir, readJson(reviewPath(dir)));
44
+ atomicWriteJson(descriptor.output, result.result);
45
+ }
46
+ /** Deliver one canonical result. It is safe to call repeatedly after crashes. */
47
+ export async function dispatchCompletion(root, dir) {
48
+ const registration = registeredInboxRoot(root);
49
+ const canonicalRoot = ticketRoot(dir);
50
+ if (registration === null || canonicalRoot !== registration.root)
51
+ throw new Error('ticket is not a canonical direct child of a registered root');
52
+ return withExclusiveDirectoryLockAsync(`${resolve(dir)}/.delivery-lock`, async () => {
53
+ const result = readTicketResult(dir);
54
+ if (result === null)
55
+ return 'none';
56
+ try {
57
+ projectReview(dir, result);
58
+ }
59
+ catch (error) {
60
+ atomicWriteJson(deliveryErrorPath(dir), { schema: 'humanloop.delivery-error/v1', responsePath: responsePath(dir), failedAt: new Date().toISOString(), error: error instanceof Error ? error.message : String(error) });
61
+ return 'pending';
62
+ }
63
+ if (registration.handler === undefined)
64
+ return 'none';
65
+ if (receiptMatches(dir))
66
+ return 'delivered';
67
+ const event = eventFor(registration.root, resolve(dir), result);
68
+ try {
69
+ await runHandler(registration.handler.command, registration.handler.args, event);
70
+ atomicWriteJson(deliveryPath(dir), { schema: 'humanloop.delivery/v1', responsePath: event.responsePath, deliveredAt: new Date().toISOString() });
71
+ try {
72
+ unlinkSync(deliveryErrorPath(dir));
73
+ }
74
+ catch { /* no prior error */ }
75
+ return 'delivered';
76
+ }
77
+ catch (error) {
78
+ atomicWriteJson(deliveryErrorPath(dir), { schema: 'humanloop.delivery-error/v1', responsePath: event.responsePath, failedAt: new Date().toISOString(), error: error instanceof Error ? error.message : String(error) });
79
+ return 'pending';
80
+ }
81
+ });
82
+ }
83
+ /** Event-driven startup/root-change reconciliation for results lacking an ack. */
84
+ export async function reconcileCompletions(root) {
85
+ const registration = registeredInboxRoot(root);
86
+ if (registration === null)
87
+ return;
88
+ let entries;
89
+ try {
90
+ entries = readdirSync(registration.root);
91
+ }
92
+ catch {
93
+ return;
94
+ }
95
+ for (const entry of entries) {
96
+ const dir = resolve(registration.root, entry);
97
+ try {
98
+ if (!statSync(dir).isDirectory())
99
+ continue;
100
+ }
101
+ catch {
102
+ continue;
103
+ }
104
+ if (ticketRoot(dir) === registration.root && readTicketResult(dir) !== null && !receiptMatches(dir))
105
+ await dispatchCompletion(registration.root, dir);
106
+ }
107
+ }
@@ -0,0 +1,74 @@
1
+ import type { InteractionResponse, TicketSummary } from '../types.js';
2
+ import type { Key } from '../tui/terminal.js';
3
+ export interface InboxControllerOptions {
4
+ roots?: string[];
5
+ cols?: number;
6
+ rows?: number;
7
+ scan?: (roots?: string[]) => TicketSummary[];
8
+ completeDeck?: (dir: string, responses: InteractionResponse[], token: string) => Promise<unknown>;
9
+ }
10
+ type Screen = 'list' | 'detail';
11
+ /** One terminal owner for scanning, stable selection, claims, and frame diffs. */
12
+ export declare class InboxController {
13
+ private readonly options;
14
+ private readonly scan;
15
+ private readonly finishDeck;
16
+ private items;
17
+ private selectedDir;
18
+ private selectedIndex;
19
+ private screen;
20
+ private adapter;
21
+ private reviewAdapter;
22
+ private claim;
23
+ private reconciling;
24
+ private suspended;
25
+ private submittingDir;
26
+ private stdinListener;
27
+ private cols;
28
+ private rows;
29
+ private frame;
30
+ private status;
31
+ private watchers;
32
+ private selectedWatcher;
33
+ private closed;
34
+ private running;
35
+ private finishRun;
36
+ constructor(options?: InboxControllerOptions);
37
+ snapshot(): {
38
+ items: TicketSummary[];
39
+ selectedDir?: string;
40
+ screen: Screen;
41
+ inputBuffer?: string;
42
+ };
43
+ rescan(): void;
44
+ invalidate(): void;
45
+ resize(cols?: number, rows?: number): void;
46
+ render(): string[];
47
+ handleKey(input: string, key: Key): void;
48
+ activate(): void;
49
+ /** Claim the review, hand the whole popup TTY to the native editor, then
50
+ * converge to the on-disk outcome when it exits. Draft/final ownership stays
51
+ * in ReviewAdapter; the controller only owns the terminal handoff. */
52
+ private activateReview;
53
+ reloadSelectedDeck(): void;
54
+ close(): void;
55
+ run(): Promise<void>;
56
+ private complete;
57
+ /** Give the raw TTY to a child process (native review editor). */
58
+ private suspendForChild;
59
+ /** Retake the TTY after the child exits and force a full repaint. */
60
+ private resumeAfterChild;
61
+ private resolvedRoots;
62
+ /** Owner-boundary reconciliation for resolved results still lacking an ack.
63
+ * Guarded so overlapping fs events cannot stack concurrent scans. */
64
+ private reconcileRoots;
65
+ private leaveDetail;
66
+ private select;
67
+ private detailSize;
68
+ private detailLines;
69
+ private withStatus;
70
+ private repaint;
71
+ private watchRoots;
72
+ private watchSelected;
73
+ }
74
+ export {};
@@ -0,0 +1,457 @@
1
+ import { readFileSync, watch } from 'node:fs';
2
+ import { getTerminalSize, parseKeypress, restoreTerminal, setupTerminal } from '../tui/terminal.js';
3
+ import { diffFrame } from '../tui/render.js';
4
+ import { renderMarkdown } from '../render/termrender.js';
5
+ import { BOLD, CYAN, DIM, RESET, YELLOW } from '../tui/ansi.js';
6
+ import { buildInboxLines } from './tui.js';
7
+ import { inboxLayout } from './layout.js';
8
+ import { scanInbox } from './scan.js';
9
+ import { inboxRootsDirectory, listInboxRoots } from './registry.js';
10
+ import { claimTicket, heartbeatClaim, releaseClaim } from './claim.js';
11
+ import { completeDeck, readTicketResult } from './tickets.js';
12
+ import { reconcileCompletions } from './completion.js';
13
+ import { clearProgress, deckPath, progressPath, readJson, reviewPath } from './convention.js';
14
+ import { DeckAdapter } from './deck-adapter.js';
15
+ import { ReviewAdapter } from './review-adapter.js';
16
+ /** One terminal owner for scanning, stable selection, claims, and frame diffs. */
17
+ export class InboxController {
18
+ options;
19
+ scan;
20
+ finishDeck;
21
+ items = [];
22
+ selectedDir;
23
+ selectedIndex = 0;
24
+ screen = 'list';
25
+ adapter;
26
+ reviewAdapter;
27
+ claim;
28
+ reconciling = false;
29
+ suspended = false;
30
+ submittingDir;
31
+ stdinListener;
32
+ cols;
33
+ rows;
34
+ frame = [];
35
+ status;
36
+ watchers = [];
37
+ selectedWatcher;
38
+ closed = false;
39
+ running = false;
40
+ finishRun;
41
+ constructor(options = {}) {
42
+ this.options = options;
43
+ this.scan = options.scan ?? scanInbox;
44
+ this.finishDeck = options.completeDeck ?? completeDeck;
45
+ const size = getTerminalSize();
46
+ this.cols = options.cols ?? size.cols;
47
+ this.rows = options.rows ?? size.rows;
48
+ this.rescan();
49
+ }
50
+ snapshot() {
51
+ return { items: [...this.items], selectedDir: this.selectedDir, screen: this.screen, inputBuffer: this.adapter?.inputBuffer() };
52
+ }
53
+ rescan() {
54
+ const priorDir = this.selectedDir;
55
+ const priorIndex = this.selectedIndex;
56
+ this.items = this.scan(this.options.roots);
57
+ const found = priorDir === undefined ? -1 : this.items.findIndex((item) => item.dir === priorDir);
58
+ if (found >= 0) {
59
+ this.selectedIndex = found;
60
+ this.selectedDir = priorDir;
61
+ return;
62
+ }
63
+ if (this.adapter !== undefined && priorDir !== undefined) {
64
+ const canceled = readTicketResult(priorDir)?.kind === 'canceled';
65
+ if (canceled)
66
+ clearProgress(priorDir);
67
+ // Suppress the "resolved elsewhere" banner when WE are the ones resolving
68
+ // this ticket: the response.json we just published trips the root watch
69
+ // mid-submit, and that is our own completion, not an external event.
70
+ if (priorDir !== this.submittingDir)
71
+ this.status = canceled ? 'canceled by requester' : 'ticket resolved elsewhere';
72
+ this.leaveDetail();
73
+ }
74
+ if (this.items.length === 0) {
75
+ this.selectedIndex = 0;
76
+ this.selectedDir = undefined;
77
+ return;
78
+ }
79
+ this.selectedIndex = Math.min(priorIndex, this.items.length - 1);
80
+ this.selectedDir = this.items[this.selectedIndex].dir;
81
+ }
82
+ invalidate() { this.rescan(); this.repaint(); }
83
+ resize(cols = getTerminalSize().cols, rows = getTerminalSize().rows) {
84
+ this.cols = cols;
85
+ this.rows = rows;
86
+ this.frame = [];
87
+ this.adapter?.resize(this.detailSize().cols, this.detailSize().rows);
88
+ this.repaint(true);
89
+ }
90
+ render() {
91
+ const geometry = inboxLayout(this.cols, this.rows, this.screen);
92
+ if (geometry.mode === 'minimum')
93
+ return [`${YELLOW}Resize terminal to at least 60×18 to use inbox.${RESET}`];
94
+ const list = buildInboxLines(this.items, geometry.listWidth, this.selectedIndex);
95
+ const detail = this.detailLines(geometry.detailWidth, geometry.height);
96
+ if (geometry.mode === 'list')
97
+ return this.withStatus(list);
98
+ if (geometry.mode === 'detail')
99
+ return this.withStatus(detail);
100
+ const lines = [];
101
+ for (let i = 0; i < geometry.height; i++) {
102
+ const left = list[i] ?? '';
103
+ const right = detail[i] ?? '';
104
+ lines.push(`${left}${' '.repeat(Math.max(1, geometry.listWidth - visibleWidth(left)))} ${right}`);
105
+ }
106
+ return this.withStatus(lines);
107
+ }
108
+ handleKey(input, key) {
109
+ // Option/Alt+I (M-i) and Ctrl-C request graceful close from ANY controller
110
+ // mode. A root-table binding cannot fire while the popup grabs client input,
111
+ // so the close-from-open path must live here — checked before adapter
112
+ // forwarding so it works in active deck freetext as well as the list.
113
+ if ((key.ctrl && input === 'c') || isToggleCloseChord(input)) {
114
+ this.close();
115
+ return;
116
+ }
117
+ if (this.screen === 'detail' && this.adapter !== undefined) {
118
+ this.adapter.handleKey(input, key);
119
+ this.repaint();
120
+ return;
121
+ }
122
+ if (key.escape || input === 'q') {
123
+ this.close();
124
+ return;
125
+ }
126
+ if (input === 'j' || key.downArrow)
127
+ this.select(this.selectedIndex + 1);
128
+ else if (input === 'k' || key.upArrow)
129
+ this.select(this.selectedIndex - 1);
130
+ else if (key.return || input === 'a')
131
+ this.activate();
132
+ this.repaint();
133
+ }
134
+ activate() {
135
+ const item = this.items[this.selectedIndex];
136
+ if (item === undefined)
137
+ return;
138
+ if (item.kind === 'review') {
139
+ void this.activateReview(item);
140
+ return;
141
+ }
142
+ if (item.kind !== 'deck')
143
+ return;
144
+ const claim = claimTicket(item.dir);
145
+ if (claim === null) {
146
+ this.status = 'ticket is being edited by another inbox';
147
+ return;
148
+ }
149
+ this.claim = { dir: item.dir, token: claim.token };
150
+ const deck = readJson(deckPath(item.dir));
151
+ if (deck === null) {
152
+ releaseClaim(item.dir, claim.token);
153
+ this.claim = undefined;
154
+ this.invalidate();
155
+ return;
156
+ }
157
+ if (item.interactionKind === 'notify') {
158
+ void this.complete([{ id: deck.interactions[0]?.id ?? 'notify', selectedOptionId: deck.interactions[0]?.options[0]?.id }]);
159
+ return;
160
+ }
161
+ this.screen = 'detail';
162
+ this.adapter = new DeckAdapter({
163
+ dir: item.dir,
164
+ deck,
165
+ cols: this.detailSize().cols,
166
+ rows: this.detailSize().rows,
167
+ onDirty: () => this.repaint(),
168
+ onBack: () => { this.leaveDetail(); this.repaint(); },
169
+ onComplete: (responses) => { void this.complete(responses); },
170
+ });
171
+ this.watchSelected(item.dir);
172
+ }
173
+ /** Claim the review, hand the whole popup TTY to the native editor, then
174
+ * converge to the on-disk outcome when it exits. Draft/final ownership stays
175
+ * in ReviewAdapter; the controller only owns the terminal handoff. */
176
+ async activateReview(item) {
177
+ const claim = claimTicket(item.dir);
178
+ if (claim === null) {
179
+ this.status = 'ticket is being edited by another inbox';
180
+ this.repaint();
181
+ return;
182
+ }
183
+ const descriptor = readJson(reviewPath(item.dir));
184
+ if (descriptor === null) {
185
+ releaseClaim(item.dir, claim.token);
186
+ this.invalidate();
187
+ return;
188
+ }
189
+ let closeRequested = false;
190
+ this.reviewAdapter = new ReviewAdapter({ dir: item.dir, descriptor, claim, onClose: () => { closeRequested = true; } });
191
+ this.suspendForChild();
192
+ try {
193
+ await this.reviewAdapter.start();
194
+ }
195
+ catch (error) {
196
+ this.status = error instanceof Error ? error.message : String(error);
197
+ }
198
+ finally {
199
+ this.reviewAdapter = undefined;
200
+ // M-i inside native review is a graceful close of the WHOLE inbox: the
201
+ // adapter already saved the draft and released the claim, so just tear the
202
+ // controller down instead of returning to the list. The ticket stays
203
+ // pending because no submit flag was written.
204
+ if (closeRequested) {
205
+ this.suspended = false;
206
+ this.close();
207
+ }
208
+ else {
209
+ this.resumeAfterChild();
210
+ this.rescan();
211
+ this.reconcileRoots();
212
+ this.repaint(true);
213
+ }
214
+ }
215
+ }
216
+ reloadSelectedDeck() { this.adapter?.reload(); this.repaint(); }
217
+ close() {
218
+ if (this.closed)
219
+ return;
220
+ this.closed = true;
221
+ void this.reviewAdapter?.stop();
222
+ this.leaveDetail();
223
+ for (const watcher of this.watchers)
224
+ watcher.close();
225
+ this.watchers = [];
226
+ this.selectedWatcher?.close();
227
+ this.selectedWatcher = undefined;
228
+ this.finishRun?.();
229
+ }
230
+ async run() {
231
+ setupTerminal();
232
+ this.running = true;
233
+ this.watchRoots();
234
+ // Close the crash window between an earlier result publication and its
235
+ // handler launch: dispatch every resolved-but-undelivered ticket now.
236
+ this.reconcileRoots();
237
+ this.repaint(true);
238
+ const heartbeat = setInterval(() => {
239
+ if (this.claim !== undefined)
240
+ heartbeatClaim(this.claim.dir, this.claim.token);
241
+ }, 10_000);
242
+ await new Promise((resolve) => {
243
+ const onData = (data) => {
244
+ const { input, key } = parseKeypress(data);
245
+ this.handleKey(input, key);
246
+ if (this.closed)
247
+ finish();
248
+ };
249
+ this.stdinListener = onData;
250
+ const onResize = () => this.resize();
251
+ let done = false;
252
+ this.finishRun = () => {
253
+ if (done)
254
+ return;
255
+ done = true;
256
+ process.stdin.removeListener('data', onData);
257
+ this.stdinListener = undefined;
258
+ process.stdout.removeListener('resize', onResize);
259
+ clearInterval(heartbeat);
260
+ this.running = false;
261
+ this.finishRun = undefined;
262
+ restoreTerminal();
263
+ resolve();
264
+ };
265
+ const finish = this.finishRun;
266
+ process.stdin.on('data', onData);
267
+ process.stdout.on('resize', onResize);
268
+ });
269
+ }
270
+ async complete(responses) {
271
+ const claim = this.claim;
272
+ if (claim === undefined)
273
+ return;
274
+ this.submittingDir = claim.dir;
275
+ try {
276
+ await this.finishDeck(claim.dir, responses, claim.token);
277
+ }
278
+ catch (error) {
279
+ this.submittingDir = undefined;
280
+ this.status = error instanceof Error ? error.message : String(error);
281
+ return;
282
+ }
283
+ this.submittingDir = undefined;
284
+ this.leaveDetail(false);
285
+ this.rescan();
286
+ this.reconcileRoots();
287
+ this.repaint();
288
+ }
289
+ /** Give the raw TTY to a child process (native review editor). */
290
+ suspendForChild() {
291
+ this.suspended = true;
292
+ if (this.stdinListener !== undefined)
293
+ process.stdin.removeListener('data', this.stdinListener);
294
+ restoreTerminal();
295
+ }
296
+ /** Retake the TTY after the child exits and force a full repaint. */
297
+ resumeAfterChild() {
298
+ this.suspended = false;
299
+ if (this.closed)
300
+ return;
301
+ setupTerminal();
302
+ if (this.stdinListener !== undefined)
303
+ process.stdin.on('data', this.stdinListener);
304
+ this.frame = [];
305
+ }
306
+ resolvedRoots() {
307
+ return this.options.roots ?? listInboxRoots().filter((root) => root.available).map((root) => root.root);
308
+ }
309
+ /** Owner-boundary reconciliation for resolved results still lacking an ack.
310
+ * Guarded so overlapping fs events cannot stack concurrent scans. */
311
+ reconcileRoots() {
312
+ if (this.reconciling)
313
+ return;
314
+ this.reconciling = true;
315
+ const roots = this.resolvedRoots();
316
+ void (async () => {
317
+ try {
318
+ for (const root of roots) {
319
+ try {
320
+ await reconcileCompletions(root);
321
+ }
322
+ catch { /* undelivered stays for the next pass */ }
323
+ }
324
+ }
325
+ finally {
326
+ this.reconciling = false;
327
+ }
328
+ })();
329
+ }
330
+ leaveDetail(release = true) {
331
+ this.adapter?.close();
332
+ this.adapter = undefined;
333
+ this.selectedWatcher?.close();
334
+ this.selectedWatcher = undefined;
335
+ if (release && this.claim !== undefined)
336
+ releaseClaim(this.claim.dir, this.claim.token);
337
+ this.claim = undefined;
338
+ this.screen = 'list';
339
+ }
340
+ select(index) {
341
+ if (this.items.length === 0)
342
+ return;
343
+ this.selectedIndex = Math.max(0, Math.min(index, this.items.length - 1));
344
+ this.selectedDir = this.items[this.selectedIndex].dir;
345
+ }
346
+ detailSize() {
347
+ const layout = inboxLayout(this.cols, this.rows, this.screen);
348
+ return { cols: Math.max(1, layout.detailWidth - 2), rows: layout.height };
349
+ }
350
+ detailLines(width, rows) {
351
+ if (this.adapter !== undefined)
352
+ return this.adapter.render();
353
+ const selected = this.items[this.selectedIndex];
354
+ if (selected === undefined)
355
+ return [` ${DIM}Select a pending interaction.${RESET}`];
356
+ const lines = [` ${BOLD}${CYAN}${selected.title}${RESET}`, '', ` ${DIM}${selected.kind} · ${sourceLabel(selected.source)}${RESET}`];
357
+ if (selected.kind === 'review') {
358
+ lines.push('', ` ${DIM}${selected.file}${RESET}`);
359
+ const draft = readJson(progressPath(selected.dir))?.comments;
360
+ const draftCount = Array.isArray(draft) ? draft.length : 0;
361
+ lines.push(` ${DIM}${draftCount} draft comment${draftCount === 1 ? '' : 's'}${RESET}`, '');
362
+ let md = '';
363
+ try {
364
+ md = readFileSync(selected.file, 'utf8');
365
+ }
366
+ catch {
367
+ md = '';
368
+ }
369
+ if (md === '')
370
+ lines.push(` ${DIM}(source file unavailable)${RESET}`);
371
+ else
372
+ for (const rendered of renderMarkdown(md, Math.max(1, width - 2)))
373
+ lines.push(` ${rendered}`);
374
+ lines.push('', ` ${DIM}Enter${RESET} start review ${DIM}j/k${RESET} select ${DIM}q${RESET} close`);
375
+ while (lines.length < rows)
376
+ lines.push('');
377
+ // Rendered markdown carries ANSI; slicing by column count would sever
378
+ // escape sequences, so review preview lines are returned unsliced.
379
+ return lines;
380
+ }
381
+ if (selected.kind === 'deck') {
382
+ const deck = readJson(deckPath(selected.dir));
383
+ if (deck !== null) {
384
+ for (const interaction of deck.interactions) {
385
+ lines.push('', ` ${BOLD}${interaction.title}${RESET}`);
386
+ for (const option of interaction.options)
387
+ lines.push(` ${DIM}• ${option.label}${RESET}`);
388
+ }
389
+ const saved = readJson(progressPath(selected.dir))?.responses;
390
+ lines.push('', ` ${DIM}${Array.isArray(saved) ? saved.length : 0} saved responses${RESET}`);
391
+ }
392
+ }
393
+ if (selected.subtitle)
394
+ lines.push('', ` ${selected.subtitle}`);
395
+ lines.push('', ` ${DIM}Enter${RESET} open ${DIM}j/k${RESET} select ${DIM}q${RESET} close`);
396
+ while (lines.length < rows)
397
+ lines.push('');
398
+ return lines.map((line) => line.slice(0, width));
399
+ }
400
+ withStatus(lines) {
401
+ if (this.status === undefined || this.rows < 1)
402
+ return lines;
403
+ const next = [...lines];
404
+ while (next.length < this.rows)
405
+ next.push('');
406
+ next[this.rows - 1] = `${YELLOW}${this.status}${RESET}`;
407
+ return next;
408
+ }
409
+ repaint(clear = false) {
410
+ // While a child owns the TTY (native review), fs-watch invalidations must
411
+ // not scribble the inbox frame over the editor's screen.
412
+ if (this.closed || !this.running || this.suspended)
413
+ return;
414
+ if (clear)
415
+ process.stdout.write('\x1b[2J\x1b[H');
416
+ const diff = diffFrame(this.frame, this.render(), this.rows, this.cols);
417
+ process.stdout.write('\x1b[?2026h');
418
+ for (const write of diff.writes)
419
+ process.stdout.write(write);
420
+ process.stdout.write('\x1b[?2026l');
421
+ this.frame = diff.nextPrevFrame;
422
+ }
423
+ watchRoots() {
424
+ for (const root of this.resolvedRoots()) {
425
+ try {
426
+ this.watchers.push(watch(root, () => { this.invalidate(); this.reconcileRoots(); }));
427
+ }
428
+ catch { /* unavailable roots remain discoverable through later rescans */ }
429
+ }
430
+ if (this.options.roots === undefined) {
431
+ try {
432
+ this.watchers.push(watch(inboxRootsDirectory(), () => this.invalidate()));
433
+ }
434
+ catch { /* registry appears after the next explicit open */ }
435
+ }
436
+ }
437
+ watchSelected(dir) {
438
+ this.selectedWatcher?.close();
439
+ try {
440
+ this.selectedWatcher = watch(dir, (_event, file) => {
441
+ if (file === 'deck.json')
442
+ this.reloadSelectedDeck();
443
+ else
444
+ this.invalidate();
445
+ });
446
+ }
447
+ catch {
448
+ this.selectedWatcher = undefined;
449
+ }
450
+ }
451
+ }
452
+ function visibleWidth(line) { return line.replace(/\x1b\[[0-9;]*m/g, '').length; }
453
+ /** M-i (Option/Alt+I) reaches the controller as the two-byte ESC-i sequence. */
454
+ function isToggleCloseChord(input) { return input === '\x1bi' || input === '\x1bI'; }
455
+ /** Prefer a human-meaningful source label, falling back to the raw node id
456
+ * before an opaque "unknown source" — a crouter ticket always carries nodeId. */
457
+ function sourceLabel(source) { return source.sessionName ?? source.askedBy ?? source.nodeId ?? 'unknown source'; }
@@ -1,26 +1,31 @@
1
1
  import type { Deck, InteractionResponse } from '../types.js';
2
2
  export declare function deckPath(dir: string): string;
3
+ export declare function reviewPath(dir: string): string;
3
4
  export declare function responsePath(dir: string): string;
4
5
  export declare function progressPath(dir: string): string;
6
+ export declare function claimPath(dir: string): string;
7
+ export declare function deliveryPath(dir: string): string;
8
+ export declare function deliveryErrorPath(dir: string): string;
5
9
  export declare function visualsDir(dir: string): string;
6
10
  export declare function visualMdPath(dir: string, id: string): string;
7
11
  export declare function visualAnsiPath(dir: string, id: string): string;
8
- export type InteractionState = 'pending' | 'in-progress' | 'resolved' | 'missing';
12
+ export type InteractionState = 'pending' | 'claimed' | 'resolved' | 'missing';
9
13
  export declare function interactionState(dir: string): InteractionState;
10
14
  export declare function isResolved(dir: string): boolean;
11
- /** Returns true if a live resolver owns this dir (progress.json mtime < 300s). */
12
15
  export declare function isClaimed(dir: string): boolean;
13
- /**
14
- * Stamp the originating canvas node id onto a deck's `source` so per-node
15
- * attention scoping (crouter's nav chrome) can attribute the ask to the node
16
- * that raised it rather than every sibling node sharing the same cwd.
17
- *
18
- * No-op when not inside a canvas node (CRTR_NODE_ID unset) or when the deck
19
- * already carries a nodeId. Mutates `deck` in place.
20
- */
21
16
  export declare function stampCanvasNode(deck: Deck): void;
22
17
  export declare function atomicWriteJson(path: string, value: unknown): void;
23
18
  export declare function readJson<T>(path: string): T | null;
19
+ /** Runs a short filesystem transition under a token-checked, crash-reclaimable directory lock. */
20
+ export declare function withExclusiveDirectoryLock<T>(path: string, operation: () => T, options?: {
21
+ staleMs?: number;
22
+ timeoutMs?: number;
23
+ }): T;
24
+ /** Async counterpart heartbeats while its operation runs, so a valid long handler is never stolen. */
25
+ export declare function withExclusiveDirectoryLockAsync<T>(path: string, operation: () => Promise<T>, options?: {
26
+ staleMs?: number;
27
+ timeoutMs?: number;
28
+ }): Promise<T>;
24
29
  export declare function writeResponse(dir: string, responses: InteractionResponse[], completedAt: string, deck?: Deck): string;
25
30
  export declare function writeProgress(dir: string, responses: InteractionResponse[]): void;
26
31
  export declare function clearProgress(dir: string): void;