@crouton-kit/humanloop 0.3.39 → 0.4.1

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 CHANGED
@@ -1,8 +1,8 @@
1
- import type { Deck, ResolutionEnvelope } from './types.js';
1
+ import type { Deck, ResolutionEnvelope, VisualProvider } from './types.js';
2
2
  export interface AskOpts {
3
3
  /** Interaction directory. Defaults to a managed temp dir under os.tmpdir(). */
4
4
  dir?: string;
5
- sessionId?: string;
5
+ visualProvider?: VisualProvider;
6
6
  cols?: number;
7
7
  rows?: number;
8
8
  }
@@ -18,6 +18,7 @@ export interface InboxOpts {
18
18
  cols?: number;
19
19
  rows?: number;
20
20
  roots?: string[];
21
+ visualProvider?: VisualProvider;
21
22
  }
22
23
  /** Open the centralized inbox controller in the current human terminal. */
23
24
  export declare function openInbox(opts?: InboxOpts): Promise<void>;
package/dist/api.js CHANGED
@@ -27,7 +27,7 @@ export async function ask(deck, opts = {}) {
27
27
  stampCanvasNode(deck);
28
28
  atomicWriteJson(deckPath(dir), deck);
29
29
  const { responses, completedAt, responsePath, deck: answeredDeck } = await resolveInteractionDir(dir, deck, {
30
- sessionId: opts.sessionId,
30
+ visualProvider: opts.visualProvider,
31
31
  cols: opts.cols,
32
32
  rows: opts.rows,
33
33
  });
@@ -48,6 +48,6 @@ export async function notify(title, body) {
48
48
  }
49
49
  /** Open the centralized inbox controller in the current human terminal. */
50
50
  export async function openInbox(opts = {}) {
51
- const controller = new InboxController({ roots: opts.roots, cols: opts.cols, rows: opts.rows });
51
+ const controller = new InboxController({ roots: opts.roots, cols: opts.cols, rows: opts.rows, visualProvider: opts.visualProvider });
52
52
  await controller.run();
53
53
  }
package/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ import { validateDeck } from './inbox/deck-schema.js';
7
7
  import { managedInboxRoot, registerInboxRoot, registeredInboxRoot, unregisterInboxRoot, listInboxRoots } from './inbox/registry.js';
8
8
  import { submitDeck, submitReview } from './inbox/tickets.js';
9
9
  import { scanInbox } from './inbox/scan.js';
10
+ import { runInboxMaintenance } from './inbox/maintenance.js';
10
11
  import { openInboxPopup } from './surfaces/inbox-popup.js';
11
12
  import { toggleInboxPopup } from './tui/tmux.js';
12
13
  import { ask } from './api.js';
@@ -47,11 +48,11 @@ function requireRegisteredRoot(root) {
47
48
  const program = new Command();
48
49
  program.name('hl').description('Humanloop durable inbox and review surface.').helpOption('-h, --help');
49
50
  const inbox = program.command('inbox').description('Open, inspect, and configure the centralized human inbox.');
50
- inbox.command('open').description('Open the inbox controller in this human TTY.').option('--root <path>', 'filter to a registered root', (value, prior) => [...prior, value], []).option('--control-socket <path>', 'popup control socket').action(async (options) => {
51
+ 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').option('--target-pane <pane>', 'tmux pane underneath the popup').action(async (options) => {
51
52
  if (!process.stdin.isTTY || !process.stdout.isTTY)
52
53
  fail('hl inbox open requires an interactive TTY; use hl inbox list for read-only output');
53
54
  try {
54
- await openInboxPopup(options.controlSocket, roots(options.root));
55
+ await openInboxPopup(options.controlSocket, roots(options.root), options.targetPane);
55
56
  }
56
57
  catch (error) {
57
58
  fail(error);
@@ -73,6 +74,9 @@ inbox.command('toggle').description('Toggle the inbox popup for one tmux client.
73
74
  process.exit(result === 'failed' || result === 'ambiguous_client' ? 1 : 0);
74
75
  });
75
76
  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))));
77
+ inbox.command('_maintain', { hidden: true }).requiredOption('--lease <path>').action(async (options) => {
78
+ await runInboxMaintenance(resolve(options.lease));
79
+ });
76
80
  const root = inbox.command('roots').description('Manage durable interaction roots.');
77
81
  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) => {
78
82
  if ((options.handlerCommand === undefined) !== (options.handlerArg.length === 0))
@@ -1,7 +1,6 @@
1
- import type { InteractionResponse, TicketSummary } from '../types.js';
1
+ import type { InteractionResponse, TicketSummary, VisualProvider } 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';
5
4
  export interface InboxControllerOptions {
6
5
  roots?: string[];
7
6
  cols?: number;
@@ -10,8 +9,10 @@ export interface InboxControllerOptions {
10
9
  completeDeck?: (dir: string, responses: InteractionResponse[], token: string) => Promise<unknown>;
11
10
  startDeckBrowser?: typeof startWebServer;
12
11
  openBrowser?: (url: string) => void;
13
- /** Test seam; production uses the standard conversation-backed visual generator. */
14
- visualGeneratorForSession?: typeof visualGeneratorForConversationSession;
12
+ visualProvider?: VisualProvider;
13
+ /** Pane underneath a tmux popup. Enables a registered focus handler to
14
+ * reveal the host surface that created the selected ticket. */
15
+ targetPane?: string;
15
16
  }
16
17
  type Screen = 'list' | 'detail';
17
18
  /** One terminal owner for scanning, stable selection, claims, and frame diffs. */
@@ -35,10 +36,10 @@ export declare class InboxController {
35
36
  private deckBrowserFinalizing;
36
37
  private deckBrowserStarting;
37
38
  private deckBrowserStartingGeneration;
39
+ private focusingSource;
38
40
  /** Invalidates an in-flight asynchronous browser start when ownership ends. */
39
41
  private deckBrowserGeneration;
40
42
  private claim;
41
- private reconciling;
42
43
  private suspended;
43
44
  private submittingDir;
44
45
  private stdinListener;
@@ -91,14 +92,19 @@ export declare class InboxController {
91
92
  /** Retake the TTY after the child exits and force a full repaint. */
92
93
  private resumeAfterChild;
93
94
  private resolvedRoots;
94
- /** Owner-boundary reconciliation for resolved results still lacking an ack.
95
- * Guarded so overlapping fs events cannot stack concurrent scans. */
96
- private reconcileRoots;
95
+ /** Automatic ticket capability is deliberately marker + current-root-handler only. */
96
+ private visualProviderFor;
97
+ private startTicketVisual;
97
98
  private leaveDetail;
98
99
  private select;
99
100
  private scrollPreview;
100
101
  private detailSize;
101
102
  private detailLines;
103
+ private focusAvailable;
104
+ /** Ask the registered host to reveal this ticket's source beside the pane
105
+ * underneath the popup. The popup closes only after the host acknowledges
106
+ * that focus succeeded, so a failure remains visible as status. */
107
+ private focusSelectedSource;
102
108
  private passiveDetailLines;
103
109
  private previewViewport;
104
110
  private withStatus;
@@ -1,4 +1,5 @@
1
1
  import { readFileSync, watch } from 'node:fs';
2
+ import { basename, join } from 'node:path';
2
3
  import { getTerminalSize, parseKeypress, restoreTerminal, setupTerminal } from '../tui/terminal.js';
3
4
  import { diffFrame } from '../tui/render.js';
4
5
  import { renderMarkdown } from '../render/termrender.js';
@@ -9,17 +10,17 @@ import { BOLD, CYAN, DIM, GRAY, RESET, YELLOW, clipLine } from '../tui/ansi.js';
9
10
  import { buildInboxLines } from './tui.js';
10
11
  import { inboxLayout } from './layout.js';
11
12
  import { scanInbox } from './scan.js';
12
- import { inboxRootsDirectory, listInboxRoots, registeredInboxRoot } from './registry.js';
13
+ import { inboxActivityPath, inboxRootsDirectory, inboxStateDirectory, listInboxRoots, registeredInboxRoot } from './registry.js';
13
14
  import { claimTicket, heartbeatClaim, releaseClaim } from './claim.js';
14
15
  import { completeDeck, readTicketResult, ticketRoot } from './tickets.js';
15
- import { reconcileCompletions } from './completion.js';
16
- import { clearProgress, deckPath, progressPath, readJson, responsePath, reviewPath } from './convention.js';
16
+ import { clearProgress, deckPath, progressPath, readJson, responsePath, reviewPath, runHandler, visualsDir } from './convention.js';
17
17
  import { DeckAdapter } from './deck-adapter.js';
18
18
  import { validateDeck } from './deck-schema.js';
19
19
  import { ReviewAdapter } from './review-adapter.js';
20
- import { visualGeneratorForConversationSession } from '../visuals/conversation.js';
21
20
  import { editBufferInEditor } from '../editor/roundtrip.js';
22
21
  import { cancelFollowUp, readFollowUp, requestFollowUp } from './followup.js';
22
+ import { cancelVisualRequest, readVisualResult, reconcileVisualRequestsForTicket, startVisualRequest, VISUAL_CAPABILITY, } from './visual.js';
23
+ import { kickInboxMaintenance } from './maintenance.js';
23
24
  /** One terminal owner for scanning, stable selection, claims, and frame diffs. */
24
25
  export class InboxController {
25
26
  options;
@@ -41,10 +42,10 @@ export class InboxController {
41
42
  deckBrowserFinalizing;
42
43
  deckBrowserStarting = false;
43
44
  deckBrowserStartingGeneration;
45
+ focusingSource = false;
44
46
  /** Invalidates an in-flight asynchronous browser start when ownership ends. */
45
47
  deckBrowserGeneration = 0;
46
48
  claim;
47
- reconciling = false;
48
49
  suspended = false;
49
50
  submittingDir;
50
51
  stdinListener;
@@ -153,6 +154,10 @@ export class InboxController {
153
154
  return;
154
155
  }
155
156
  if (this.screen === 'detail' && this.adapter !== undefined) {
157
+ if ((input === 'g' || input === 'G') && this.adapter.canAcceptHostKeys()) {
158
+ void this.focusSelectedSource();
159
+ return;
160
+ }
156
161
  if ((input === 'w' || input === 'W') && this.adapter.canAcceptHostKeys()) {
157
162
  void this.openDeckBrowser();
158
163
  return;
@@ -176,6 +181,8 @@ export class InboxController {
176
181
  this.select(this.selectedIndex + 1);
177
182
  else if (input === 'k' || key.upArrow)
178
183
  this.select(this.selectedIndex - 1);
184
+ else if (input === 'g' || input === 'G')
185
+ void this.focusSelectedSource();
179
186
  else if (key.return || input === 'a')
180
187
  this.activate();
181
188
  this.repaint();
@@ -196,13 +203,20 @@ export class InboxController {
196
203
  return;
197
204
  }
198
205
  this.claim = { dir: item.dir, token: claim.token };
199
- const deck = readJson(deckPath(item.dir));
200
- if (deck === null) {
206
+ const deck = this.readDeck(item.dir);
207
+ if (deck === undefined) {
201
208
  releaseClaim(item.dir, claim.token);
202
209
  this.claim = undefined;
203
210
  this.invalidate();
204
211
  return;
205
212
  }
213
+ const root = ticketRoot(item.dir);
214
+ if (root !== null) {
215
+ // A newly acquired claim makes every older running generation stale
216
+ // before the panel can mint its own work. This only dispatches cleanup.
217
+ const retirement = reconcileVisualRequestsForTicket(root, item.dir, claim.token);
218
+ void retirement.delivery.finally(kickInboxMaintenance);
219
+ }
206
220
  // Notifications use the same canonical deck panel as every other deck:
207
221
  // opening is not acknowledgement; panel completion is.
208
222
  this.activeDeck = deck;
@@ -214,7 +228,7 @@ export class InboxController {
214
228
  cols: this.detailSize().cols,
215
229
  rows: this.detailSize().rows,
216
230
  onDirty: () => this.repaint(),
217
- generateVisual: deck.source?.originatingConversationSessionId === undefined ? undefined : (this.options.visualGeneratorForSession ?? visualGeneratorForConversationSession)(deck.source.originatingConversationSessionId),
231
+ visualProvider: this.visualProviderFor(item.dir, deck, claim.token),
218
232
  onEditorRequest: () => this.editActiveDeckInput(),
219
233
  followUpAvailable: followUp.available,
220
234
  onFollowUpRequest: followUp.onRequest,
@@ -264,7 +278,6 @@ export class InboxController {
264
278
  else {
265
279
  this.resumeAfterChild();
266
280
  this.rescan();
267
- this.reconcileRoots();
268
281
  this.repaint(true);
269
282
  }
270
283
  }
@@ -282,7 +295,8 @@ export class InboxController {
282
295
  this.activeDeck = deck;
283
296
  const followUp = this.followUpHandlers(this.claim.dir, deck);
284
297
  this.adapter.setFollowUpHandlers(followUp.available, followUp.onRequest, followUp.onCancel);
285
- this.adapter.reload(deck);
298
+ // Reload replaces the capability before it mints the next panel generation.
299
+ this.adapter.reload(deck, this.visualProviderFor(this.claim.dir, deck, this.claim.token));
286
300
  if (followUp.available)
287
301
  this.adapter.setFollowUpState(this.followUpViewState(this.claim.dir));
288
302
  this.repaint();
@@ -418,7 +432,6 @@ export class InboxController {
418
432
  if (this.claim?.dir === dir)
419
433
  this.leaveDetail();
420
434
  this.rescan();
421
- this.reconcileRoots();
422
435
  this.repaint();
423
436
  }
424
437
  /** Return browser authority to the terminal deck without changing its draft. */
@@ -473,10 +486,9 @@ export class InboxController {
473
486
  setupTerminal();
474
487
  this.running = true;
475
488
  this.watchRoots();
476
- // Close the crash window between an earlier result publication and its
477
- // handler launch: dispatch every resolved-but-undelivered ticket now.
478
- this.reconcileRoots();
479
489
  this.repaint(true);
490
+ // Crash repair is durable but never part of a human keystroke.
491
+ kickInboxMaintenance();
480
492
  const heartbeat = setInterval(() => {
481
493
  if (this.claim !== undefined)
482
494
  heartbeatClaim(this.claim.dir, this.claim.token);
@@ -525,7 +537,6 @@ export class InboxController {
525
537
  this.submittingDir = undefined;
526
538
  this.leaveDetail(false);
527
539
  this.rescan();
528
- this.reconcileRoots();
529
540
  this.repaint();
530
541
  }
531
542
  /** The controller owns the terminal handoff and repaint around $EDITOR. */
@@ -567,26 +578,71 @@ export class InboxController {
567
578
  resolvedRoots() {
568
579
  return this.options.roots ?? listInboxRoots().filter((root) => root.available).map((root) => root.root);
569
580
  }
570
- /** Owner-boundary reconciliation for resolved results still lacking an ack.
571
- * Guarded so overlapping fs events cannot stack concurrent scans. */
572
- reconcileRoots() {
573
- if (this.reconciling)
574
- return;
575
- this.reconciling = true;
576
- const roots = this.resolvedRoots();
577
- void (async () => {
581
+ /** Automatic ticket capability is deliberately marker + current-root-handler only. */
582
+ visualProviderFor(dir, deck, claimToken) {
583
+ // An inline provider is intentional host injection, independent of ticket capability.
584
+ if (this.options.visualProvider !== undefined)
585
+ return this.options.visualProvider;
586
+ const root = ticketRoot(dir);
587
+ if (root === null || deck.source?.visual !== VISUAL_CAPABILITY || registeredInboxRoot(root)?.visualHandler === undefined)
588
+ return undefined;
589
+ return (request) => this.startTicketVisual(root, dir, claimToken, request);
590
+ }
591
+ startTicketVisual(root, dir, claimToken, request) {
592
+ let watcher;
593
+ let settled = false;
594
+ let settle;
595
+ const result = new Promise((resolve) => { settle = resolve; });
596
+ const finish = (outcome) => {
597
+ if (settled)
598
+ return;
599
+ settled = true;
600
+ watcher?.close();
601
+ watcher = undefined;
602
+ settle(outcome);
603
+ };
604
+ let started;
605
+ try {
606
+ started = startVisualRequest({ root, dir, claimToken, request });
607
+ }
608
+ catch (error) {
609
+ finish({ status: 'error', error: error instanceof Error ? error.message : String(error) });
610
+ return { result, cancel: () => { } };
611
+ }
612
+ const reread = () => {
613
+ if (settled)
614
+ return;
578
615
  try {
579
- for (const root of roots) {
580
- try {
581
- await reconcileCompletions(root);
582
- }
583
- catch { /* undelivered stays for the next pass */ }
584
- }
616
+ const outcome = readVisualResult(root, dir, request.requestId);
617
+ if (outcome === null)
618
+ return;
619
+ finish(outcome.status === 'ready'
620
+ ? { status: 'ready', markdown: outcome.markdown }
621
+ : { status: 'error', error: outcome.error });
585
622
  }
586
- finally {
587
- this.reconciling = false;
623
+ catch (error) {
624
+ finish({ status: 'error', error: error instanceof Error ? error.message : String(error) });
588
625
  }
589
- })();
626
+ };
627
+ try {
628
+ // Install the watch before the first durable reread so a publication in
629
+ // the narrow setup window is either observed or found by that reread.
630
+ watcher = watch(join(visualsDir(dir), request.requestId), () => reread());
631
+ watcher.once('error', (error) => finish({ status: 'error', error: error.message }));
632
+ reread();
633
+ }
634
+ catch (error) {
635
+ finish({ status: 'error', error: error instanceof Error ? error.message : String(error) });
636
+ }
637
+ void started.delivery.then(reread, (error) => finish({ status: 'error', error: error instanceof Error ? error.message : String(error) }));
638
+ return {
639
+ result,
640
+ cancel: () => {
641
+ watcher?.close();
642
+ watcher = undefined;
643
+ void cancelVisualRequest(root, dir, request.requestId).finally(kickInboxMaintenance);
644
+ },
645
+ };
590
646
  }
591
647
  leaveDetail(release = true) {
592
648
  this.deckBrowserGeneration++;
@@ -629,11 +685,42 @@ export class InboxController {
629
685
  const selected = this.items[this.selectedIndex];
630
686
  if (selected === undefined)
631
687
  return this.previewViewport(this.passiveDetailLines(width), width, rows);
688
+ const focusHint = this.focusAvailable(selected.dir) ? ` ${DIM}g${RESET} chat` : '';
632
689
  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`];
690
+ ? [` ${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${focusHint} ${DIM}q${RESET} close`]
691
+ : [` ${DIM}Enter${RESET} opens the full review ${DIM}u/d${RESET} scroll ${DIM}j/k${RESET} select`, ` ${focusHint} ${DIM}q${RESET} close`];
635
692
  return [...this.previewViewport(this.passiveDetailLines(width), width, Math.max(0, rows - footer.length)), ...footer.map((line) => clipLine(line, width))];
636
693
  }
694
+ focusAvailable(dir) {
695
+ const root = ticketRoot(dir);
696
+ return this.options.targetPane !== undefined && root !== null && registeredInboxRoot(root)?.focusHandler !== undefined;
697
+ }
698
+ /** Ask the registered host to reveal this ticket's source beside the pane
699
+ * underneath the popup. The popup closes only after the host acknowledges
700
+ * that focus succeeded, so a failure remains visible as status. */
701
+ async focusSelectedSource() {
702
+ const item = this.items[this.selectedIndex];
703
+ const targetPane = this.options.targetPane;
704
+ if (item === undefined || targetPane === undefined || this.focusingSource)
705
+ return;
706
+ const root = ticketRoot(item.dir);
707
+ const registration = root === null ? null : registeredInboxRoot(root);
708
+ if (root === null || registration?.focusHandler === undefined)
709
+ return;
710
+ const event = { schema: 'humanloop.focus/v1', root, dir: item.dir, ticketId: item.id, targetPane };
711
+ this.focusingSource = true;
712
+ try {
713
+ await runHandler(registration.focusHandler.command, registration.focusHandler.args, event);
714
+ this.close();
715
+ }
716
+ catch (error) {
717
+ this.status = error instanceof Error ? error.message : String(error);
718
+ this.repaint();
719
+ }
720
+ finally {
721
+ this.focusingSource = false;
722
+ }
723
+ }
637
724
  passiveDetailLines(width) {
638
725
  const selected = this.items[this.selectedIndex];
639
726
  if (selected === undefined)
@@ -727,15 +814,21 @@ export class InboxController {
727
814
  this.frame = diff.nextPrevFrame;
728
815
  }
729
816
  watchRoots() {
730
- for (const root of this.resolvedRoots()) {
731
- try {
732
- this.watchers.push(watch(root, () => { this.invalidate(); this.reconcileRoots(); }));
733
- }
734
- catch { /* unavailable roots remain discoverable through later rescans */ }
817
+ // A single state-level activity marker replaces one watcher per historical
818
+ // root. Closing hundreds of fs watchers was itself a multi-second UI path.
819
+ const activity = basename(inboxActivityPath());
820
+ try {
821
+ this.watchers.push(watch(inboxStateDirectory(), (_event, file) => {
822
+ if (String(file) === activity) {
823
+ this.invalidate();
824
+ kickInboxMaintenance();
825
+ }
826
+ }));
735
827
  }
828
+ catch { /* the state directory appears when the next ticket is submitted */ }
736
829
  if (this.options.roots === undefined) {
737
830
  try {
738
- this.watchers.push(watch(inboxRootsDirectory(), () => this.invalidate()));
831
+ this.watchers.push(watch(inboxRootsDirectory(), () => { this.invalidate(); kickInboxMaintenance(); }));
739
832
  }
740
833
  catch { /* registry appears after the next explicit open */ }
741
834
  }
@@ -9,8 +9,6 @@ export declare function deliveryErrorPath(dir: string): string;
9
9
  export declare function followupRequestPath(dir: string): string;
10
10
  export declare function followupResultPath(dir: string): string;
11
11
  export declare function visualsDir(dir: string): string;
12
- export declare function visualMdPath(dir: string, id: string): string;
13
- export declare function visualAnsiPath(dir: string, id: string): string;
14
12
  /** Spawns a handler `{command,args}` with `event` as JSON on stdin. Exit 0
15
13
  * acknowledges; a nonzero exit, spawn error, or 30s timeout rejects with an
16
14
  * Error carrying the handler's captured stderr. Shared by completion delivery
@@ -22,6 +20,8 @@ export declare function isResolved(dir: string): boolean;
22
20
  export declare function isClaimed(dir: string): boolean;
23
21
  export declare function stampCanvasNode(deck: Deck): void;
24
22
  export declare function atomicWriteJson(path: string, value: unknown): void;
23
+ /** Publish one immutable JSON record without replacing an existing winner. */
24
+ export declare function publishJsonExclusive(path: string, value: unknown): boolean;
25
25
  export declare function readJson<T>(path: string): T | null;
26
26
  /** Runs a short filesystem transition under a token-checked, crash-reclaimable directory lock. */
27
27
  export declare function withExclusiveDirectoryLock<T>(path: string, operation: () => T, options?: {
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, linkSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { dirname } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
@@ -13,8 +13,6 @@ export function deliveryErrorPath(dir) { return `${dir}/delivery-error.json`; }
13
13
  export function followupRequestPath(dir) { return `${dir}/followup-request.json`; }
14
14
  export function followupResultPath(dir) { return `${dir}/followup-result.json`; }
15
15
  export function visualsDir(dir) { return `${dir}/visuals`; }
16
- export function visualMdPath(dir, id) { return `${dir}/visuals/${id}.md`; }
17
- export function visualAnsiPath(dir, id) { return `${dir}/visuals/${id}.ansi`; }
18
16
  /** Spawns a handler `{command,args}` with `event` as JSON on stdin. Exit 0
19
17
  * acknowledges; a nonzero exit, spawn error, or 30s timeout rejects with an
20
18
  * Error carrying the handler's captured stderr. Shared by completion delivery
@@ -65,6 +63,24 @@ export function atomicWriteJson(path, value) {
65
63
  writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
66
64
  renameSync(tmp, path);
67
65
  }
66
+ /** Publish one immutable JSON record without replacing an existing winner. */
67
+ export function publishJsonExclusive(path, value) {
68
+ const tmp = `${path}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
69
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
70
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
71
+ try {
72
+ linkSync(tmp, path);
73
+ return true;
74
+ }
75
+ catch (error) {
76
+ if (error.code === 'EEXIST')
77
+ return false;
78
+ throw error;
79
+ }
80
+ finally {
81
+ unlinkSync(tmp);
82
+ }
83
+ }
68
84
  export function readJson(path) {
69
85
  try {
70
86
  return JSON.parse(readFileSync(path, 'utf8'));
@@ -1,4 +1,4 @@
1
- import type { Deck, FollowUpState, GenerateVisual, InteractionResponse } from '../types.js';
1
+ import type { Deck, FollowUpState, InteractionResponse, VisualProvider } from '../types.js';
2
2
  import type { Key } from '../tui/terminal.js';
3
3
  export interface DeckAdapterOptions {
4
4
  dir: string;
@@ -9,7 +9,7 @@ export interface DeckAdapterOptions {
9
9
  onComplete: (responses: InteractionResponse[]) => void;
10
10
  onBack: () => void;
11
11
  onDirty: () => void;
12
- generateVisual?: GenerateVisual;
12
+ visualProvider?: VisualProvider;
13
13
  onEditorRequest?: () => void;
14
14
  followUpAvailable?: boolean;
15
15
  onFollowUpRequest?: (question: string) => void;
@@ -30,6 +30,6 @@ export declare class DeckAdapter {
30
30
  canAcceptHostKeys(): boolean;
31
31
  handleKey(input: string, key: Key): void;
32
32
  /** Fresh descriptor reads preserve mounted answers for interaction ids still present. */
33
- reload(deck: Deck): void;
33
+ reload(deck: Deck, visualProvider: VisualProvider | undefined): void;
34
34
  close(): void;
35
35
  }
@@ -20,7 +20,7 @@ export class DeckAdapter {
20
20
  onExit: () => { if (notificationsAcknowledged(opts.deck, this.responses))
21
21
  opts.onComplete(this.responses); },
22
22
  onDirty: opts.onDirty,
23
- generateVisual: opts.generateVisual,
23
+ visualProvider: opts.visualProvider,
24
24
  onEditorRequest: opts.onEditorRequest,
25
25
  followUpAvailable: opts.followUpAvailable,
26
26
  onFollowUpRequest: opts.onFollowUpRequest,
@@ -42,8 +42,8 @@ export class DeckAdapter {
42
42
  this.panel.handleKey(input, key);
43
43
  }
44
44
  /** Fresh descriptor reads preserve mounted answers for interaction ids still present. */
45
- reload(deck) {
46
- this.panel.loadDeck(deck, { progressPath: progressPath(this.opts.dir) });
45
+ reload(deck, visualProvider) {
46
+ this.panel.loadDeck(deck, { progressPath: progressPath(this.opts.dir), visualProvider });
47
47
  this.opts.onDirty();
48
48
  }
49
49
  close() { this.panel.unmount(); }
@@ -18,7 +18,7 @@ export declare const deckSchema: z.ZodObject<{
18
18
  askedBy: z.ZodOptional<z.ZodString>;
19
19
  blockedSince: z.ZodOptional<z.ZodString>;
20
20
  nodeId: z.ZodOptional<z.ZodString>;
21
- originatingConversationSessionId: z.ZodOptional<z.ZodString>;
21
+ visual: z.ZodOptional<z.ZodLiteral<"humanloop.visual/v1">>;
22
22
  }, z.core.$strip>>;
23
23
  interactions: z.ZodArray<z.ZodObject<{
24
24
  id: z.ZodString;
@@ -59,7 +59,7 @@ export declare const reviewDescriptorSchema: z.ZodObject<{
59
59
  askedBy: z.ZodOptional<z.ZodString>;
60
60
  blockedSince: z.ZodOptional<z.ZodString>;
61
61
  nodeId: z.ZodOptional<z.ZodString>;
62
- originatingConversationSessionId: z.ZodOptional<z.ZodString>;
62
+ visual: z.ZodOptional<z.ZodLiteral<"humanloop.visual/v1">>;
63
63
  }, z.core.$strip>;
64
64
  blockedSince: z.ZodString;
65
65
  }, z.core.$strict>;
@@ -1,6 +1,6 @@
1
1
  import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
2
2
  import { basename, dirname, isAbsolute, resolve, sep } from 'node:path';
3
- import { claimPath, deckPath, deliveryErrorPath, deliveryPath, followupRequestPath, followupResultPath, progressPath, responsePath, reviewPath } from './convention.js';
3
+ import { claimPath, deckPath, deliveryErrorPath, deliveryPath, followupRequestPath, followupResultPath, progressPath, responsePath, reviewPath, visualsDir } from './convention.js';
4
4
  import { z } from 'zod';
5
5
  import { INTERACTION_KINDS } from '../types.js';
6
6
  import { checkMarkdown } from '../render/termrender.js';
@@ -11,7 +11,7 @@ const interactionSchema = z.object({
11
11
  title: z.string().min(1), subtitle: z.string().min(1).optional(), body: z.string().optional(), bodyPath: z.string().optional(),
12
12
  options: z.array(interactionOptionSchema), multiSelect: z.boolean().optional(), allowFreetext: z.boolean().optional(), freetextLabel: z.string().optional(), kind: z.enum(INTERACTION_KINDS).optional(), preAnswered: preAnswerSchema.optional(),
13
13
  });
14
- const deckSourceSchema = z.object({ sessionName: z.string().optional(), askedBy: z.string().optional(), blockedSince: z.string().optional(), nodeId: z.string().optional(), originatingConversationSessionId: z.string().min(1).optional() });
14
+ const deckSourceSchema = z.object({ sessionName: z.string().optional(), askedBy: z.string().optional(), blockedSince: z.string().optional(), nodeId: z.string().optional(), visual: z.literal('humanloop.visual/v1').optional() });
15
15
  export const deckSchema = z.object({ title: z.string().optional(), source: deckSourceSchema.optional(), interactions: z.array(interactionSchema).min(1) }).superRefine((input, ctx) => {
16
16
  const seen = new Set();
17
17
  input.interactions.forEach((interaction, index) => {
@@ -77,8 +77,8 @@ export function validateReviewProjection(dir, parsed) {
77
77
  throw new Error('review file must be an existing absolute markdown file');
78
78
  const file = realpathSync(descriptor.file);
79
79
  const output = resolve(realpathSync(dirname(descriptor.output)), basename(descriptor.output));
80
- const reserved = new Set(['deck.json', 'review.json', 'response.json', 'progress.json', 'claim.json', 'delivery.json', 'delivery-error.json', 'followup-request.json', 'followup-result.json']);
81
- const ownProtocolPaths = new Set([deckPath(dir), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir), deliveryPath(dir), deliveryErrorPath(dir), followupRequestPath(dir), followupResultPath(dir)]);
80
+ const reserved = new Set(['deck.json', 'review.json', 'response.json', 'progress.json', 'claim.json', 'delivery.json', 'delivery-error.json', 'followup-request.json', 'followup-result.json', 'visuals']);
81
+ const ownProtocolPaths = new Set([deckPath(dir), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir), deliveryPath(dir), deliveryErrorPath(dir), followupRequestPath(dir), followupResultPath(dir), visualsDir(dir)]);
82
82
  if (output === file || reserved.has(basename(output)) || ownProtocolPaths.has(output))
83
83
  throw new Error('review output must not alias the source or ticket protocol files');
84
84
  return { ...descriptor, file, output };
@@ -0,0 +1,4 @@
1
+ /** Start one detached repair pass. The lease makes repeated UI gestures free. */
2
+ export declare function kickInboxMaintenance(): void;
3
+ /** Run repair and stay alive only until the durable cleanup retry queue is empty. */
4
+ export declare function runInboxMaintenance(lease: string): Promise<void>;