@crouton-kit/humanloop 0.3.38 → 0.3.39

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 (43) hide show
  1. package/dist/browser/server.d.ts +15 -5
  2. package/dist/browser/server.js +110 -14
  3. package/dist/cli.js +0 -0
  4. package/dist/conversation/reader.d.ts +15 -1
  5. package/dist/conversation/reader.js +320 -30
  6. package/dist/editor/roundtrip.d.ts +5 -0
  7. package/dist/editor/roundtrip.js +36 -0
  8. package/dist/inbox/completion.js +1 -27
  9. package/dist/inbox/controller.d.ts +26 -2
  10. package/dist/inbox/controller.js +265 -34
  11. package/dist/inbox/convention.d.ts +7 -0
  12. package/dist/inbox/convention.js +32 -0
  13. package/dist/inbox/deck-adapter.d.ts +10 -2
  14. package/dist/inbox/deck-adapter.js +23 -12
  15. package/dist/inbox/deck-schema.d.ts +2 -0
  16. package/dist/inbox/deck-schema.js +4 -4
  17. package/dist/inbox/followup.d.ts +57 -0
  18. package/dist/inbox/followup.js +117 -0
  19. package/dist/inbox/registry.d.ts +2 -0
  20. package/dist/inbox/registry.js +6 -2
  21. package/dist/inbox/tickets.js +8 -2
  22. package/dist/inbox/tui.d.ts +1 -1
  23. package/dist/inbox/tui.js +58 -25
  24. package/dist/index.d.ts +3 -1
  25. package/dist/index.js +1 -0
  26. package/dist/tui/app.d.ts +1 -1
  27. package/dist/tui/app.js +80 -88
  28. package/dist/tui/input.d.ts +5 -1
  29. package/dist/tui/input.js +41 -15
  30. package/dist/tui/log.d.ts +2 -0
  31. package/dist/tui/log.js +53 -0
  32. package/dist/tui/render.js +40 -23
  33. package/dist/tui/terminal.d.ts +1 -0
  34. package/dist/tui/terminal.js +5 -0
  35. package/dist/tui/tmux.js +44 -22
  36. package/dist/types.d.ts +39 -2
  37. package/dist/visuals/conversation.d.ts +7 -0
  38. package/dist/visuals/conversation.js +16 -0
  39. package/dist/visuals/generate.d.ts +3 -1
  40. package/dist/visuals/generate.js +8 -6
  41. package/dist/web/assets/{index-DhbBiRqS.js → index-YFwgZZMg.js} +2 -2
  42. package/dist/web/index.html +1 -1
  43. package/package.json +7 -2
@@ -1,7 +1,6 @@
1
1
  import { readdirSync, statSync, unlinkSync } from 'node:fs';
2
- import { spawn } from 'node:child_process';
3
2
  import { basename, resolve } from 'node:path';
4
- import { atomicWriteJson, deliveryErrorPath, deliveryPath, responsePath, readJson, reviewPath, withExclusiveDirectoryLockAsync } from './convention.js';
3
+ import { atomicWriteJson, deliveryErrorPath, deliveryPath, responsePath, readJson, reviewPath, runHandler, withExclusiveDirectoryLockAsync } from './convention.js';
5
4
  import { registeredInboxRoot } from './registry.js';
6
5
  import { readTicketResult, ticketRoot } from './tickets.js';
7
6
  import { validateReviewProjection } from './deck-schema.js';
@@ -12,31 +11,6 @@ function receiptMatches(dir) {
12
11
  function eventFor(root, dir, result) {
13
12
  return { schema: 'humanloop.completion/v1', root, dir, ticketId: basename(dir), kind: result.kind, outcome: result.kind === 'canceled' ? 'canceled' : 'resolved', responsePath: responsePath(dir) };
14
13
  }
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
14
  function projectReview(dir, result) {
41
15
  if (result.kind !== 'review')
42
16
  return;
@@ -1,6 +1,7 @@
1
1
  import type { InteractionResponse, TicketSummary } from '../types.js';
2
2
  import type { Key } from '../tui/terminal.js';
3
3
  import { startWebServer } from '../browser/server.js';
4
+ import { visualGeneratorForConversationSession } from '../visuals/conversation.js';
4
5
  export interface InboxControllerOptions {
5
6
  roots?: string[];
6
7
  cols?: number;
@@ -9,6 +10,8 @@ export interface InboxControllerOptions {
9
10
  completeDeck?: (dir: string, responses: InteractionResponse[], token: string) => Promise<unknown>;
10
11
  startDeckBrowser?: typeof startWebServer;
11
12
  openBrowser?: (url: string) => void;
13
+ /** Test seam; production uses the standard conversation-backed visual generator. */
14
+ visualGeneratorForSession?: typeof visualGeneratorForConversationSession;
12
15
  }
13
16
  type Screen = 'list' | 'detail';
14
17
  /** One terminal owner for scanning, stable selection, claims, and frame diffs. */
@@ -19,11 +22,21 @@ export declare class InboxController {
19
22
  private items;
20
23
  private selectedDir;
21
24
  private selectedIndex;
25
+ /** Scroll state belongs to the passive preview, never to an editable deck. */
26
+ private previewScrollOffset;
22
27
  private screen;
23
28
  private adapter;
29
+ private activeDeck;
24
30
  private reviewAdapter;
25
31
  private deckBrowser;
32
+ /** Blocks terminal input until browser finalization/shutdown is quiescent. */
33
+ private deckBrowserTakingBack;
34
+ /** The single accepted submit that crossed the browser boundary. */
35
+ private deckBrowserFinalizing;
26
36
  private deckBrowserStarting;
37
+ private deckBrowserStartingGeneration;
38
+ /** Invalidates an in-flight asynchronous browser start when ownership ends. */
39
+ private deckBrowserGeneration;
27
40
  private claim;
28
41
  private reconciling;
29
42
  private suspended;
@@ -56,16 +69,24 @@ export declare class InboxController {
56
69
  * in ReviewAdapter; the controller only owns the terminal handoff. */
57
70
  private activateReview;
58
71
  reloadSelectedDeck(): void;
72
+ private followUpHandlers;
73
+ private readDeck;
74
+ private followUpViewState;
75
+ private refreshFollowUp;
59
76
  /** Hand the selected deck to its browser surface while retaining this ticket's claim. */
60
77
  private openDeckBrowser;
61
- /** The browser has atomically published the response; reconcile its owner delivery. */
78
+ /** Finalize through this controller's claim-safe lifecycle before HTTP acks. */
79
+ private finalizeDeckBrowser;
80
+ /** The browser has finalized through the controller; reconcile its owner delivery. */
62
81
  private finishDeckBrowser;
63
82
  /** Return browser authority to the terminal deck without changing its draft. */
64
83
  private takeBackDeckBrowser;
65
84
  close(): void;
66
85
  run(): Promise<void>;
67
86
  private complete;
68
- /** Give the raw TTY to a child process (native review editor). */
87
+ /** The controller owns the terminal handoff and repaint around $EDITOR. */
88
+ private editActiveDeckInput;
89
+ /** Give the raw TTY to a child process (native review editor or $EDITOR). */
69
90
  private suspendForChild;
70
91
  /** Retake the TTY after the child exits and force a full repaint. */
71
92
  private resumeAfterChild;
@@ -75,8 +96,11 @@ export declare class InboxController {
75
96
  private reconcileRoots;
76
97
  private leaveDetail;
77
98
  private select;
99
+ private scrollPreview;
78
100
  private detailSize;
79
101
  private detailLines;
102
+ private passiveDetailLines;
103
+ private previewViewport;
80
104
  private withStatus;
81
105
  private repaint;
82
106
  private watchRoots;
@@ -9,13 +9,17 @@ import { BOLD, CYAN, DIM, GRAY, RESET, YELLOW, clipLine } from '../tui/ansi.js';
9
9
  import { buildInboxLines } from './tui.js';
10
10
  import { inboxLayout } from './layout.js';
11
11
  import { scanInbox } from './scan.js';
12
- import { inboxRootsDirectory, listInboxRoots } from './registry.js';
12
+ import { inboxRootsDirectory, listInboxRoots, registeredInboxRoot } from './registry.js';
13
13
  import { claimTicket, heartbeatClaim, releaseClaim } from './claim.js';
14
- import { completeDeck, readTicketResult } from './tickets.js';
14
+ import { completeDeck, readTicketResult, ticketRoot } from './tickets.js';
15
15
  import { reconcileCompletions } from './completion.js';
16
- import { clearProgress, deckPath, progressPath, readJson, reviewPath } from './convention.js';
16
+ import { clearProgress, deckPath, progressPath, readJson, responsePath, reviewPath } from './convention.js';
17
17
  import { DeckAdapter } from './deck-adapter.js';
18
+ import { validateDeck } from './deck-schema.js';
18
19
  import { ReviewAdapter } from './review-adapter.js';
20
+ import { visualGeneratorForConversationSession } from '../visuals/conversation.js';
21
+ import { editBufferInEditor } from '../editor/roundtrip.js';
22
+ import { cancelFollowUp, readFollowUp, requestFollowUp } from './followup.js';
19
23
  /** One terminal owner for scanning, stable selection, claims, and frame diffs. */
20
24
  export class InboxController {
21
25
  options;
@@ -24,11 +28,21 @@ export class InboxController {
24
28
  items = [];
25
29
  selectedDir;
26
30
  selectedIndex = 0;
31
+ /** Scroll state belongs to the passive preview, never to an editable deck. */
32
+ previewScrollOffset = 0;
27
33
  screen = 'list';
28
34
  adapter;
35
+ activeDeck;
29
36
  reviewAdapter;
30
37
  deckBrowser;
38
+ /** Blocks terminal input until browser finalization/shutdown is quiescent. */
39
+ deckBrowserTakingBack = false;
40
+ /** The single accepted submit that crossed the browser boundary. */
41
+ deckBrowserFinalizing;
31
42
  deckBrowserStarting = false;
43
+ deckBrowserStartingGeneration;
44
+ /** Invalidates an in-flight asynchronous browser start when ownership ends. */
45
+ deckBrowserGeneration = 0;
32
46
  claim;
33
47
  reconciling = false;
34
48
  suspended = false;
@@ -79,10 +93,12 @@ export class InboxController {
79
93
  if (this.items.length === 0) {
80
94
  this.selectedIndex = 0;
81
95
  this.selectedDir = undefined;
96
+ this.previewScrollOffset = 0;
82
97
  return;
83
98
  }
84
99
  this.selectedIndex = Math.min(priorIndex, this.items.length - 1);
85
100
  this.selectedDir = this.items[this.selectedIndex].dir;
101
+ this.previewScrollOffset = 0;
86
102
  }
87
103
  invalidate() { this.rescan(); this.repaint(); }
88
104
  resize(cols = getTerminalSize().cols, rows = getTerminalSize().rows) {
@@ -96,7 +112,7 @@ export class InboxController {
96
112
  const geometry = inboxLayout(this.cols, this.rows, this.screen);
97
113
  if (geometry.mode === 'minimum')
98
114
  return [`${YELLOW}Resize terminal to at least 60×18 to use inbox.${RESET}`];
99
- const list = buildInboxLines(this.items, geometry.listWidth, this.selectedIndex);
115
+ const list = buildInboxLines(this.items, geometry.listWidth, this.selectedIndex, geometry.height);
100
116
  const detail = this.detailLines(geometry.detailWidth, geometry.height);
101
117
  if (geometry.mode === 'list')
102
118
  return this.withStatus(list);
@@ -126,6 +142,11 @@ export class InboxController {
126
142
  this.close();
127
143
  return;
128
144
  }
145
+ // Take-back is an ownership boundary, not an instantaneous UI toggle:
146
+ // discard every key until a pre-existing browser finalizer and the listener
147
+ // have both settled, so terminal edits cannot race browser completion.
148
+ if (this.deckBrowserTakingBack)
149
+ return;
129
150
  if (this.deckBrowser !== undefined) {
130
151
  if (input === 'w' || input === 'W')
131
152
  void this.takeBackDeckBrowser();
@@ -144,7 +165,14 @@ export class InboxController {
144
165
  this.close();
145
166
  return;
146
167
  }
147
- if (input === 'j' || key.downArrow)
168
+ // Passive previews share the deck's documented scroll bindings without
169
+ // claiming or mounting an editable panel. Ctrl+E/Y are line-wise aliases;
170
+ // u/d and Ctrl+U/D/Page keys move a useful chunk.
171
+ if (input === 'd' || key.pageDown || (key.ctrl && (input === 'd' || input === 'e')))
172
+ this.scrollPreview(input === 'e' ? 1 : 10);
173
+ else if (input === 'u' || key.pageUp || (key.ctrl && (input === 'u' || input === 'y')))
174
+ this.scrollPreview(input === 'y' ? -1 : -10);
175
+ else if (input === 'j' || key.downArrow)
148
176
  this.select(this.selectedIndex + 1);
149
177
  else if (input === 'k' || key.upArrow)
150
178
  this.select(this.selectedIndex - 1);
@@ -175,10 +203,10 @@ export class InboxController {
175
203
  this.invalidate();
176
204
  return;
177
205
  }
178
- if (item.interactionKind === 'notify') {
179
- void this.complete([{ id: deck.interactions[0]?.id ?? 'notify', selectedOptionId: deck.interactions[0]?.options[0]?.id }]);
180
- return;
181
- }
206
+ // Notifications use the same canonical deck panel as every other deck:
207
+ // opening is not acknowledgement; panel completion is.
208
+ this.activeDeck = deck;
209
+ const followUp = this.followUpHandlers(item.dir, deck);
182
210
  this.screen = 'detail';
183
211
  this.adapter = new DeckAdapter({
184
212
  dir: item.dir,
@@ -186,9 +214,16 @@ export class InboxController {
186
214
  cols: this.detailSize().cols,
187
215
  rows: this.detailSize().rows,
188
216
  onDirty: () => this.repaint(),
217
+ generateVisual: deck.source?.originatingConversationSessionId === undefined ? undefined : (this.options.visualGeneratorForSession ?? visualGeneratorForConversationSession)(deck.source.originatingConversationSessionId),
218
+ onEditorRequest: () => this.editActiveDeckInput(),
219
+ followUpAvailable: followUp.available,
220
+ onFollowUpRequest: followUp.onRequest,
221
+ onFollowUpCancel: followUp.onCancel,
189
222
  onBack: () => { this.leaveDetail(); this.repaint(); },
190
223
  onComplete: (responses) => { void this.complete(responses); },
191
224
  });
225
+ if (followUp.available)
226
+ this.adapter.setFollowUpState(this.followUpViewState(item.dir));
192
227
  this.watchSelected(item.dir);
193
228
  }
194
229
  /** Claim the review, hand the whole popup TTY to the native editor, then
@@ -234,7 +269,72 @@ export class InboxController {
234
269
  }
235
270
  }
236
271
  }
237
- reloadSelectedDeck() { this.adapter?.reload(); this.repaint(); }
272
+ reloadSelectedDeck() {
273
+ if (this.adapter === undefined || this.claim === undefined) {
274
+ this.repaint();
275
+ return;
276
+ }
277
+ const deck = this.readDeck(this.claim.dir);
278
+ if (deck === undefined) {
279
+ this.repaint();
280
+ return;
281
+ }
282
+ this.activeDeck = deck;
283
+ const followUp = this.followUpHandlers(this.claim.dir, deck);
284
+ this.adapter.setFollowUpHandlers(followUp.available, followUp.onRequest, followUp.onCancel);
285
+ this.adapter.reload(deck);
286
+ if (followUp.available)
287
+ this.adapter.setFollowUpState(this.followUpViewState(this.claim.dir));
288
+ this.repaint();
289
+ }
290
+ followUpHandlers(dir, deck) {
291
+ const root = ticketRoot(dir);
292
+ const available = root !== null && registeredInboxRoot(root)?.followUpHandler !== undefined && isAnswerBearingDeck(deck);
293
+ if (!available)
294
+ return { available: false };
295
+ return {
296
+ available: true,
297
+ onRequest: (question) => {
298
+ requestFollowUp(root, dir, { question });
299
+ this.refreshFollowUp(dir);
300
+ },
301
+ onCancel: () => {
302
+ cancelFollowUp(root, dir);
303
+ this.refreshFollowUp(dir);
304
+ },
305
+ };
306
+ }
307
+ readDeck(dir) {
308
+ const deck = readJson(deckPath(dir));
309
+ if (deck === null)
310
+ return undefined;
311
+ try {
312
+ return validateDeck(deck);
313
+ }
314
+ catch {
315
+ return undefined;
316
+ }
317
+ }
318
+ followUpViewState(dir) {
319
+ const { request, result } = readFollowUp(dir);
320
+ if (request === null)
321
+ return { status: 'idle' };
322
+ if (result !== null && result.requestId === request.requestId) {
323
+ return result.status === 'ready'
324
+ ? { status: 'ready', markdown: result.markdown }
325
+ : { status: 'error', error: result.error };
326
+ }
327
+ return request.state === 'running' ? { status: 'running' } : { status: 'idle' };
328
+ }
329
+ refreshFollowUp(dir) {
330
+ if (this.adapter === undefined || this.claim?.dir !== dir || this.activeDeck === undefined)
331
+ return;
332
+ const followUp = this.followUpHandlers(dir, this.activeDeck);
333
+ this.adapter.setFollowUpHandlers(followUp.available, followUp.onRequest, followUp.onCancel);
334
+ if (followUp.available)
335
+ this.adapter.setFollowUpState(this.followUpViewState(dir));
336
+ this.repaint();
337
+ }
238
338
  /** Hand the selected deck to its browser surface while retaining this ticket's claim. */
239
339
  async openDeckBrowser() {
240
340
  if (this.deckBrowser !== undefined || this.deckBrowserStarting || this.adapter === undefined || this.claim === undefined)
@@ -246,26 +346,71 @@ export class InboxController {
246
346
  if (deck === null)
247
347
  return;
248
348
  this.deckBrowserStarting = true;
349
+ const generation = ++this.deckBrowserGeneration;
350
+ this.deckBrowserStartingGeneration = generation;
351
+ const claim = this.claim;
352
+ const adapter = this.adapter;
249
353
  try {
250
354
  const start = this.options.startDeckBrowser ?? startWebServer;
251
355
  let browser;
252
356
  browser = await start({
253
357
  dir: item.dir,
254
358
  deck,
255
- onSubmit: () => { void this.finishDeckBrowser(item.dir, browser); },
359
+ finalize: async (responses) => this.finalizeDeckBrowser(item.dir, claim.token, generation, responses),
360
+ onSubmit: () => this.finishDeckBrowser(item.dir, browser),
256
361
  });
362
+ // Closing, taking back, or losing the claim while listen() was pending
363
+ // retires this start. Stop the fresh listener before it can open a tab.
364
+ if (this.closed || generation !== this.deckBrowserGeneration || this.claim?.token !== claim.token || this.adapter !== adapter) {
365
+ await browser.stop();
366
+ return;
367
+ }
257
368
  this.deckBrowser = browser;
258
369
  (this.options.openBrowser ?? openBrowser)(browser.url);
259
370
  }
260
371
  catch (error) {
261
- this.status = error instanceof Error ? error.message : String(error);
372
+ if (!this.closed && generation === this.deckBrowserGeneration)
373
+ this.status = error instanceof Error ? error.message : String(error);
262
374
  }
263
375
  finally {
264
- this.deckBrowserStarting = false;
376
+ if (this.deckBrowserStartingGeneration === generation) {
377
+ this.deckBrowserStarting = false;
378
+ this.deckBrowserStartingGeneration = undefined;
379
+ }
265
380
  this.repaint();
266
381
  }
267
382
  }
268
- /** The browser has atomically published the response; reconcile its owner delivery. */
383
+ /** Finalize through this controller's claim-safe lifecycle before HTTP acks. */
384
+ async finalizeDeckBrowser(dir, token, generation, responses) {
385
+ // The generation advances synchronously at take-back's ownership boundary.
386
+ // A request that reaches the listener after that point must fail before it
387
+ // can write, even while stop() is still closing HTTP connections.
388
+ if (this.deckBrowserTakingBack || generation !== this.deckBrowserGeneration || this.claim?.dir !== dir || this.claim.token !== token) {
389
+ throw new Error('browser handoff no longer owns this ticket');
390
+ }
391
+ // The server's HTTP single-assignment marker is published after its
392
+ // finalizer resolves, so simultaneous tabs can both reach us first. Share
393
+ // the first finalizer rather than starting another write or replacing the
394
+ // promise take-back must wait on.
395
+ if (this.deckBrowserFinalizing !== undefined)
396
+ return this.deckBrowserFinalizing;
397
+ const finalizing = (async () => {
398
+ await this.finishDeck(dir, responses, token);
399
+ const result = readTicketResult(dir);
400
+ if (result?.kind !== 'deck')
401
+ throw new Error('ticket did not produce a deck result');
402
+ return { completedAt: result.completedAt, responsePath: responsePath(dir) };
403
+ })();
404
+ this.deckBrowserFinalizing = finalizing;
405
+ try {
406
+ return await finalizing;
407
+ }
408
+ finally {
409
+ if (this.deckBrowserFinalizing === finalizing)
410
+ this.deckBrowserFinalizing = undefined;
411
+ }
412
+ }
413
+ /** The browser has finalized through the controller; reconcile its owner delivery. */
269
414
  async finishDeckBrowser(dir, browser) {
270
415
  if (this.deckBrowser === browser)
271
416
  this.deckBrowser = undefined;
@@ -279,14 +424,29 @@ export class InboxController {
279
424
  /** Return browser authority to the terminal deck without changing its draft. */
280
425
  async takeBackDeckBrowser() {
281
426
  const browser = this.deckBrowser;
282
- if (browser === undefined)
427
+ if (browser === undefined || this.deckBrowserTakingBack)
283
428
  return;
284
- this.deckBrowser = undefined;
429
+ // Invalidate newly-arriving browser submits before awaiting anything. Keep
430
+ // the handoff mounted and terminal input blocked until its running submit
431
+ // (if any) has conclusively won or failed and the listener is gone.
432
+ this.deckBrowserTakingBack = true;
433
+ this.deckBrowserGeneration++;
285
434
  try {
435
+ // A request that crossed the server boundary before this ownership change
436
+ // must own its normal finish path: it includes the HTTP 200 flush and
437
+ // onSubmit convergence before it stops the listener. Do not force-close
438
+ // that response after merely observing controller persistence.
439
+ const lifecycle = browser.pendingSubmitLifecycle?.();
440
+ if (lifecycle !== undefined && await lifecycle.catch(() => false))
441
+ return;
286
442
  await browser.requestTakeBack();
287
443
  await browser.stop();
288
444
  }
289
445
  finally {
446
+ if (this.deckBrowser === browser)
447
+ this.deckBrowser = undefined;
448
+ this.deckBrowserTakingBack = false;
449
+ this.rescan();
290
450
  this.repaint(true);
291
451
  }
292
452
  }
@@ -294,6 +454,9 @@ export class InboxController {
294
454
  if (this.closed)
295
455
  return;
296
456
  this.closed = true;
457
+ this.deckBrowserGeneration++;
458
+ this.deckBrowserTakingBack = false;
459
+ this.deckBrowserStarting = false;
297
460
  void this.reviewAdapter?.stop();
298
461
  const browser = this.deckBrowser;
299
462
  this.deckBrowser = undefined;
@@ -365,7 +528,26 @@ export class InboxController {
365
528
  this.reconcileRoots();
366
529
  this.repaint();
367
530
  }
368
- /** Give the raw TTY to a child process (native review editor). */
531
+ /** The controller owns the terminal handoff and repaint around $EDITOR. */
532
+ editActiveDeckInput() {
533
+ const buffer = this.adapter?.inputBuffer();
534
+ if (buffer === undefined)
535
+ return;
536
+ this.suspendForChild();
537
+ let result = { text: buffer };
538
+ try {
539
+ result = editBufferInEditor(buffer);
540
+ }
541
+ finally {
542
+ this.resumeAfterChild();
543
+ this.adapter?.setInputBuffer(result.text);
544
+ this.resize();
545
+ if (result.error !== undefined)
546
+ this.status = result.error;
547
+ this.repaint(true);
548
+ }
549
+ }
550
+ /** Give the raw TTY to a child process (native review editor or $EDITOR). */
369
551
  suspendForChild() {
370
552
  this.suspended = true;
371
553
  if (this.stdinListener !== undefined)
@@ -407,8 +589,13 @@ export class InboxController {
407
589
  })();
408
590
  }
409
591
  leaveDetail(release = true) {
592
+ this.deckBrowserGeneration++;
593
+ // A stale listener will stop itself after its await, but this controller
594
+ // may immediately claim another ticket and start a fresh browser handoff.
595
+ this.deckBrowserStarting = false;
410
596
  this.adapter?.close();
411
597
  this.adapter = undefined;
598
+ this.activeDeck = undefined;
412
599
  this.selectedWatcher?.close();
413
600
  this.selectedWatcher = undefined;
414
601
  if (release && this.claim !== undefined)
@@ -419,9 +606,15 @@ export class InboxController {
419
606
  select(index) {
420
607
  if (this.items.length === 0)
421
608
  return;
422
- this.selectedIndex = Math.max(0, Math.min(index, this.items.length - 1));
609
+ const next = Math.max(0, Math.min(index, this.items.length - 1));
610
+ if (next !== this.selectedIndex)
611
+ this.previewScrollOffset = 0;
612
+ this.selectedIndex = next;
423
613
  this.selectedDir = this.items[this.selectedIndex].dir;
424
614
  }
615
+ scrollPreview(delta) {
616
+ this.previewScrollOffset = Math.max(0, this.previewScrollOffset + delta);
617
+ }
425
618
  detailSize() {
426
619
  const layout = inboxLayout(this.cols, this.rows, this.screen);
427
620
  return { cols: Math.max(1, layout.detailWidth - 2), rows: layout.height };
@@ -434,6 +627,15 @@ export class InboxController {
434
627
  if (this.adapter !== undefined)
435
628
  return this.adapter.render();
436
629
  const selected = this.items[this.selectedIndex];
630
+ if (selected === undefined)
631
+ return this.previewViewport(this.passiveDetailLines(width), width, rows);
632
+ const footer = selected.kind === 'deck'
633
+ ? [` ${DIM}Enter${RESET} opens the full ticket ${DIM}u/d${RESET} scroll ${DIM}j/k${RESET} select`, ` ${DIM}Active ask:${RESET} c comment u/d scroll w browser ${DIM}q${RESET} close`]
634
+ : [` ${DIM}Enter${RESET} opens the full review ${DIM}u/d${RESET} scroll ${DIM}j/k${RESET} select`, ` ${DIM}q${RESET} close`];
635
+ return [...this.previewViewport(this.passiveDetailLines(width), width, Math.max(0, rows - footer.length)), ...footer.map((line) => clipLine(line, width))];
636
+ }
637
+ passiveDetailLines(width) {
638
+ const selected = this.items[this.selectedIndex];
437
639
  if (selected === undefined)
438
640
  return [` ${DIM}Select a pending interaction.${RESET}`];
439
641
  const lines = [` ${BOLD}${CYAN}${selected.title}${RESET}`, '', ` ${DIM}${selected.kind} · ${sourceLabel(selected.source)}${RESET}`];
@@ -454,18 +656,18 @@ export class InboxController {
454
656
  else
455
657
  for (const rendered of renderMarkdown(md, Math.max(1, width - 2)))
456
658
  lines.push(` ${rendered}`);
457
- lines.push('', ` ${DIM}Enter${RESET} start review ${DIM}j/k${RESET} select ${DIM}q${RESET} close`);
458
- while (lines.length < rows)
459
- lines.push('');
460
- // Rendered markdown carries ANSI; slicing by column count would sever
461
- // escape sequences, so review preview lines are returned unsliced.
462
- return lines;
463
659
  }
464
- if (selected.kind === 'deck') {
660
+ else {
465
661
  const deck = readJson(deckPath(selected.dir));
466
662
  if (deck !== null) {
467
663
  for (const interaction of deck.interactions) {
468
664
  lines.push('', ` ${BOLD}${interaction.title}${RESET}`);
665
+ if (interaction.subtitle)
666
+ for (const rendered of renderMarkdown(interaction.subtitle, Math.max(1, width - 2)))
667
+ lines.push(` ${rendered}`);
668
+ if (interaction.body)
669
+ for (const rendered of renderMarkdown(interaction.body, Math.max(1, width - 2)))
670
+ lines.push(` ${rendered}`);
469
671
  for (const option of interaction.options)
470
672
  lines.push(` ${DIM}• ${option.label}${RESET}`);
471
673
  }
@@ -473,12 +675,33 @@ export class InboxController {
473
675
  lines.push('', ` ${DIM}${Array.isArray(saved) ? saved.length : 0} saved responses${RESET}`);
474
676
  }
475
677
  }
476
- if (selected.subtitle)
477
- lines.push('', ` ${selected.subtitle}`);
478
- lines.push('', ` ${DIM}Enter${RESET} open ${DIM}j/k${RESET} select ${DIM}q${RESET} close`);
479
- while (lines.length < rows)
480
- lines.push('');
481
- return lines.map((line) => line.slice(0, width));
678
+ return lines.map((line) => clipLine(line, width));
679
+ }
680
+ previewViewport(lines, width, rows) {
681
+ if (rows < 1)
682
+ return [];
683
+ const maxOffset = Math.max(0, lines.length - Math.max(1, rows - 1));
684
+ this.previewScrollOffset = Math.min(this.previewScrollOffset, maxOffset);
685
+ const start = this.previewScrollOffset;
686
+ const hasAbove = start > 0;
687
+ // Reserve an indicator row before selecting content so a remaining tail is
688
+ // always signalled instead of silently disappearing below the viewport.
689
+ let contentRows = rows - (hasAbove ? 1 : 0);
690
+ let end = Math.min(lines.length, start + contentRows);
691
+ const hasBelow = end < lines.length;
692
+ if (hasBelow) {
693
+ contentRows--;
694
+ end = Math.min(lines.length, start + Math.max(0, contentRows));
695
+ }
696
+ const out = [];
697
+ if (hasAbove)
698
+ out.push(` ${DIM}↑ more above${RESET}`);
699
+ out.push(...lines.slice(start, end));
700
+ if (hasBelow)
701
+ out.push(` ${DIM}↓ more below${RESET}`);
702
+ while (out.length < rows)
703
+ out.push('');
704
+ return out.map((line) => clipLine(line, width));
482
705
  }
483
706
  withStatus(lines) {
484
707
  if (this.status === undefined || this.rows < 1)
@@ -521,10 +744,15 @@ export class InboxController {
521
744
  this.selectedWatcher?.close();
522
745
  try {
523
746
  this.selectedWatcher = watch(dir, (_event, file) => {
524
- if (file === 'deck.json')
747
+ if (file === 'deck.json') {
525
748
  this.reloadSelectedDeck();
526
- else
527
- this.invalidate();
749
+ return;
750
+ }
751
+ if (file === 'followup-result.json' || file === 'followup-request.json') {
752
+ this.refreshFollowUp(dir);
753
+ return;
754
+ }
755
+ this.invalidate();
528
756
  });
529
757
  }
530
758
  catch {
@@ -535,6 +763,9 @@ export class InboxController {
535
763
  function visibleWidth(line) { return line.replace(/\x1b\[[0-9;]*m/g, '').length; }
536
764
  /** M-i (Option/Alt+I) reaches the controller as the two-byte ESC-i sequence. */
537
765
  function isToggleCloseChord(input) { return input === '\x1bi' || input === '\x1bI'; }
766
+ function isAnswerBearingDeck(deck) {
767
+ return deck.interactions.some((interaction) => interaction.kind !== 'notify');
768
+ }
538
769
  /** Prefer a human-meaningful source label, falling back to the raw node id
539
770
  * before an opaque "unknown source" — a crouter ticket always carries nodeId. */
540
771
  function sourceLabel(source) { return source.sessionName ?? source.askedBy ?? source.nodeId ?? 'unknown source'; }
@@ -6,9 +6,16 @@ export declare function progressPath(dir: string): string;
6
6
  export declare function claimPath(dir: string): string;
7
7
  export declare function deliveryPath(dir: string): string;
8
8
  export declare function deliveryErrorPath(dir: string): string;
9
+ export declare function followupRequestPath(dir: string): string;
10
+ export declare function followupResultPath(dir: string): string;
9
11
  export declare function visualsDir(dir: string): string;
10
12
  export declare function visualMdPath(dir: string, id: string): string;
11
13
  export declare function visualAnsiPath(dir: string, id: string): string;
14
+ /** Spawns a handler `{command,args}` with `event` as JSON on stdin. Exit 0
15
+ * acknowledges; a nonzero exit, spawn error, or 30s timeout rejects with an
16
+ * Error carrying the handler's captured stderr. Shared by completion delivery
17
+ * and follow-up dispatch — both invoke a registered handler the same way. */
18
+ export declare function runHandler(command: string, args: string[], event: unknown): Promise<void>;
12
19
  export type InteractionState = 'pending' | 'claimed' | 'resolved' | 'missing';
13
20
  export declare function interactionState(dir: string): InteractionState;
14
21
  export declare function isResolved(dir: string): boolean;
@@ -1,4 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs';
2
+ import { spawn } from 'node:child_process';
2
3
  import { dirname } from 'node:path';
3
4
  import { randomUUID } from 'node:crypto';
4
5
  import { buildSummary } from '../summary.js';
@@ -9,9 +10,40 @@ export function progressPath(dir) { return `${dir}/progress.json`; }
9
10
  export function claimPath(dir) { return `${dir}/claim.json`; }
10
11
  export function deliveryPath(dir) { return `${dir}/delivery.json`; }
11
12
  export function deliveryErrorPath(dir) { return `${dir}/delivery-error.json`; }
13
+ export function followupRequestPath(dir) { return `${dir}/followup-request.json`; }
14
+ export function followupResultPath(dir) { return `${dir}/followup-result.json`; }
12
15
  export function visualsDir(dir) { return `${dir}/visuals`; }
13
16
  export function visualMdPath(dir, id) { return `${dir}/visuals/${id}.md`; }
14
17
  export function visualAnsiPath(dir, id) { return `${dir}/visuals/${id}.ansi`; }
18
+ /** Spawns a handler `{command,args}` with `event` as JSON on stdin. Exit 0
19
+ * acknowledges; a nonzero exit, spawn error, or 30s timeout rejects with an
20
+ * Error carrying the handler's captured stderr. Shared by completion delivery
21
+ * and follow-up dispatch — both invoke a registered handler the same way. */
22
+ export async function runHandler(command, args, event) {
23
+ await new Promise((resolvePromise, rejectPromise) => {
24
+ // stdout stays ignored (a handler acknowledges by exit code, never stdout),
25
+ // but stderr is captured so a nonzero exit surfaces the handler's own
26
+ // diagnostics in the delivery-error record instead of a bare exit code.
27
+ const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'pipe'] });
28
+ let stderr = '';
29
+ child.stderr?.on('data', (chunk) => { stderr += chunk; if (stderr.length > 8192)
30
+ stderr = stderr.slice(-8192); });
31
+ let timedOut = false;
32
+ const timeout = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 30_000);
33
+ child.once('error', (error) => { clearTimeout(timeout); rejectPromise(error); });
34
+ child.once('exit', (code, signal) => {
35
+ clearTimeout(timeout);
36
+ const detail = stderr.trim() === '' ? '' : `: ${stderr.trim()}`;
37
+ if (timedOut)
38
+ rejectPromise(new Error(`handler timed out after 30 seconds${detail}`));
39
+ else if (code === 0)
40
+ resolvePromise();
41
+ else
42
+ rejectPromise(new Error(`handler failed (${signal ?? code ?? 'unknown'})${detail}`));
43
+ });
44
+ child.stdin.end(`${JSON.stringify(event)}\n`);
45
+ });
46
+ }
15
47
  export function interactionState(dir) {
16
48
  if (!existsSync(deckPath(dir)) && !existsSync(reviewPath(dir)))
17
49
  return 'missing';