@crouton-kit/humanloop 0.3.39 → 0.4.0

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
  }
@@ -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,7 @@ 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;
15
13
  }
16
14
  type Screen = 'list' | 'detail';
17
15
  /** One terminal owner for scanning, stable selection, claims, and frame diffs. */
@@ -39,6 +37,8 @@ export declare class InboxController {
39
37
  private deckBrowserGeneration;
40
38
  private claim;
41
39
  private reconciling;
40
+ private visualReconciling;
41
+ private readonly visualCleanupExecutor;
42
42
  private suspended;
43
43
  private submittingDir;
44
44
  private stdinListener;
@@ -94,6 +94,11 @@ export declare class InboxController {
94
94
  /** Owner-boundary reconciliation for resolved results still lacking an ack.
95
95
  * Guarded so overlapping fs events cannot stack concurrent scans. */
96
96
  private reconcileRoots;
97
+ /** Reconciliation owns stale-claim retirement and durable cleanup, never a Visual start. */
98
+ private reconcileVisualWork;
99
+ /** Automatic ticket capability is deliberately marker + current-root-handler only. */
100
+ private visualProviderFor;
101
+ private startTicketVisual;
97
102
  private leaveDetail;
98
103
  private select;
99
104
  private scrollPreview;
@@ -1,4 +1,5 @@
1
1
  import { readFileSync, watch } from 'node:fs';
2
+ import { 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';
@@ -13,13 +14,68 @@ import { inboxRootsDirectory, listInboxRoots, registeredInboxRoot } from './regi
13
14
  import { claimTicket, heartbeatClaim, releaseClaim } from './claim.js';
14
15
  import { completeDeck, readTicketResult, ticketRoot } from './tickets.js';
15
16
  import { reconcileCompletions } from './completion.js';
16
- import { clearProgress, deckPath, progressPath, readJson, responsePath, reviewPath } from './convention.js';
17
+ import { clearProgress, deckPath, progressPath, readJson, responsePath, reviewPath, visualsDir } from './convention.js';
17
18
  import { DeckAdapter } from './deck-adapter.js';
18
19
  import { validateDeck } from './deck-schema.js';
19
20
  import { ReviewAdapter } from './review-adapter.js';
20
- import { visualGeneratorForConversationSession } from '../visuals/conversation.js';
21
21
  import { editBufferInEditor } from '../editor/roundtrip.js';
22
22
  import { cancelFollowUp, readFollowUp, requestFollowUp } from './followup.js';
23
+ import { cancelVisualRequest, dispatchVisualCleanup, listVisualCleanupObligationsForRoot, readVisualResult, reconcileStaleVisualRequestsForRoot, reconcileVisualRequestsForTicket, startVisualRequest, VISUAL_CAPABILITY, } from './visual.js';
24
+ /** One controller-owned retry loop for durable cancellation only. */
25
+ class VisualCleanupExecutor {
26
+ roots;
27
+ timer;
28
+ dispatching = new Set();
29
+ stopped = false;
30
+ constructor(roots) {
31
+ this.roots = roots;
32
+ }
33
+ reconcile() {
34
+ if (this.stopped)
35
+ return;
36
+ let tasks = [];
37
+ for (const root of this.roots()) {
38
+ try {
39
+ tasks.push(...listVisualCleanupObligationsForRoot(root));
40
+ }
41
+ catch { /* the next root reconciliation retries durable work */ }
42
+ }
43
+ tasks = tasks.filter((task) => !this.dispatching.has(this.key(task)));
44
+ const now = Date.now();
45
+ for (const task of tasks.filter((candidate) => Date.parse(candidate.nextAttemptAt) <= now))
46
+ this.dispatch(task);
47
+ this.arm(tasks.filter((task) => !this.dispatching.has(this.key(task))));
48
+ }
49
+ stop() {
50
+ this.stopped = true;
51
+ if (this.timer !== undefined)
52
+ clearTimeout(this.timer);
53
+ this.timer = undefined;
54
+ }
55
+ dispatch(task) {
56
+ const key = this.key(task);
57
+ if (this.dispatching.has(key))
58
+ return;
59
+ this.dispatching.add(key);
60
+ void dispatchVisualCleanup(task.root, task.dir, task.requestId).catch(() => undefined).finally(() => {
61
+ this.dispatching.delete(key);
62
+ this.reconcile();
63
+ });
64
+ }
65
+ arm(tasks) {
66
+ if (this.timer !== undefined)
67
+ clearTimeout(this.timer);
68
+ this.timer = undefined;
69
+ const earliest = tasks.reduce((next, task) => {
70
+ const due = Date.parse(task.nextAttemptAt);
71
+ return Number.isFinite(due) && (next === undefined || due < next) ? due : next;
72
+ }, undefined);
73
+ if (earliest === undefined)
74
+ return;
75
+ this.timer = setTimeout(() => { this.timer = undefined; this.reconcile(); }, Math.max(0, earliest - Date.now()));
76
+ }
77
+ key(task) { return `${task.root}\u0000${task.dir}\u0000${task.requestId}`; }
78
+ }
23
79
  /** One terminal owner for scanning, stable selection, claims, and frame diffs. */
24
80
  export class InboxController {
25
81
  options;
@@ -45,6 +101,8 @@ export class InboxController {
45
101
  deckBrowserGeneration = 0;
46
102
  claim;
47
103
  reconciling = false;
104
+ visualReconciling = false;
105
+ visualCleanupExecutor;
48
106
  suspended = false;
49
107
  submittingDir;
50
108
  stdinListener;
@@ -64,7 +122,9 @@ export class InboxController {
64
122
  const size = getTerminalSize();
65
123
  this.cols = options.cols ?? size.cols;
66
124
  this.rows = options.rows ?? size.rows;
125
+ this.visualCleanupExecutor = new VisualCleanupExecutor(() => this.resolvedRoots());
67
126
  this.rescan();
127
+ this.reconcileVisualWork();
68
128
  }
69
129
  snapshot() {
70
130
  return { items: [...this.items], selectedDir: this.selectedDir, screen: this.screen, inputBuffer: this.adapter?.inputBuffer() };
@@ -196,13 +256,20 @@ export class InboxController {
196
256
  return;
197
257
  }
198
258
  this.claim = { dir: item.dir, token: claim.token };
199
- const deck = readJson(deckPath(item.dir));
200
- if (deck === null) {
259
+ const deck = this.readDeck(item.dir);
260
+ if (deck === undefined) {
201
261
  releaseClaim(item.dir, claim.token);
202
262
  this.claim = undefined;
203
263
  this.invalidate();
204
264
  return;
205
265
  }
266
+ const root = ticketRoot(item.dir);
267
+ if (root !== null) {
268
+ // A newly acquired claim makes every older running generation stale
269
+ // before the panel can mint its own work. This only dispatches cleanup.
270
+ const retirement = reconcileVisualRequestsForTicket(root, item.dir, claim.token);
271
+ void retirement.delivery.finally(() => this.visualCleanupExecutor.reconcile());
272
+ }
206
273
  // Notifications use the same canonical deck panel as every other deck:
207
274
  // opening is not acknowledgement; panel completion is.
208
275
  this.activeDeck = deck;
@@ -214,7 +281,7 @@ export class InboxController {
214
281
  cols: this.detailSize().cols,
215
282
  rows: this.detailSize().rows,
216
283
  onDirty: () => this.repaint(),
217
- generateVisual: deck.source?.originatingConversationSessionId === undefined ? undefined : (this.options.visualGeneratorForSession ?? visualGeneratorForConversationSession)(deck.source.originatingConversationSessionId),
284
+ visualProvider: this.visualProviderFor(item.dir, deck, claim.token),
218
285
  onEditorRequest: () => this.editActiveDeckInput(),
219
286
  followUpAvailable: followUp.available,
220
287
  onFollowUpRequest: followUp.onRequest,
@@ -282,7 +349,8 @@ export class InboxController {
282
349
  this.activeDeck = deck;
283
350
  const followUp = this.followUpHandlers(this.claim.dir, deck);
284
351
  this.adapter.setFollowUpHandlers(followUp.available, followUp.onRequest, followUp.onCancel);
285
- this.adapter.reload(deck);
352
+ // Reload replaces the capability before it mints the next panel generation.
353
+ this.adapter.reload(deck, this.visualProviderFor(this.claim.dir, deck, this.claim.token));
286
354
  if (followUp.available)
287
355
  this.adapter.setFollowUpState(this.followUpViewState(this.claim.dir));
288
356
  this.repaint();
@@ -462,6 +530,7 @@ export class InboxController {
462
530
  this.deckBrowser = undefined;
463
531
  void browser?.stop();
464
532
  this.leaveDetail();
533
+ this.visualCleanupExecutor.stop();
465
534
  for (const watcher of this.watchers)
466
535
  watcher.close();
467
536
  this.watchers = [];
@@ -587,6 +656,97 @@ export class InboxController {
587
656
  this.reconciling = false;
588
657
  }
589
658
  })();
659
+ this.reconcileVisualWork();
660
+ }
661
+ /** Reconciliation owns stale-claim retirement and durable cleanup, never a Visual start. */
662
+ reconcileVisualWork() {
663
+ if (this.visualReconciling)
664
+ return;
665
+ this.visualReconciling = true;
666
+ const roots = this.resolvedRoots();
667
+ void (async () => {
668
+ try {
669
+ const deliveries = roots.flatMap((root) => {
670
+ try {
671
+ return reconcileStaleVisualRequestsForRoot(root).map((entry) => entry.delivery);
672
+ }
673
+ catch {
674
+ return [];
675
+ }
676
+ });
677
+ await Promise.all(deliveries);
678
+ }
679
+ finally {
680
+ this.visualReconciling = false;
681
+ this.visualCleanupExecutor.reconcile();
682
+ }
683
+ })();
684
+ }
685
+ /** Automatic ticket capability is deliberately marker + current-root-handler only. */
686
+ visualProviderFor(dir, deck, claimToken) {
687
+ // An inline provider is intentional host injection, independent of ticket capability.
688
+ if (this.options.visualProvider !== undefined)
689
+ return this.options.visualProvider;
690
+ const root = ticketRoot(dir);
691
+ if (root === null || deck.source?.visual !== VISUAL_CAPABILITY || registeredInboxRoot(root)?.visualHandler === undefined)
692
+ return undefined;
693
+ return (request) => this.startTicketVisual(root, dir, claimToken, request);
694
+ }
695
+ startTicketVisual(root, dir, claimToken, request) {
696
+ let watcher;
697
+ let settled = false;
698
+ let settle;
699
+ const result = new Promise((resolve) => { settle = resolve; });
700
+ const finish = (outcome) => {
701
+ if (settled)
702
+ return;
703
+ settled = true;
704
+ watcher?.close();
705
+ watcher = undefined;
706
+ settle(outcome);
707
+ };
708
+ let started;
709
+ try {
710
+ started = startVisualRequest({ root, dir, claimToken, request });
711
+ }
712
+ catch (error) {
713
+ finish({ status: 'error', error: error instanceof Error ? error.message : String(error) });
714
+ return { result, cancel: () => { } };
715
+ }
716
+ const reread = () => {
717
+ if (settled)
718
+ return;
719
+ try {
720
+ const outcome = readVisualResult(root, dir, request.requestId);
721
+ if (outcome === null)
722
+ return;
723
+ finish(outcome.status === 'ready'
724
+ ? { status: 'ready', markdown: outcome.markdown }
725
+ : { status: 'error', error: outcome.error });
726
+ }
727
+ catch (error) {
728
+ finish({ status: 'error', error: error instanceof Error ? error.message : String(error) });
729
+ }
730
+ };
731
+ try {
732
+ // Install the watch before the first durable reread so a publication in
733
+ // the narrow setup window is either observed or found by that reread.
734
+ watcher = watch(join(visualsDir(dir), request.requestId), () => reread());
735
+ watcher.once('error', (error) => finish({ status: 'error', error: error.message }));
736
+ reread();
737
+ }
738
+ catch (error) {
739
+ finish({ status: 'error', error: error instanceof Error ? error.message : String(error) });
740
+ }
741
+ void started.delivery.then(reread, (error) => finish({ status: 'error', error: error instanceof Error ? error.message : String(error) }));
742
+ return {
743
+ result,
744
+ cancel: () => {
745
+ watcher?.close();
746
+ watcher = undefined;
747
+ void cancelVisualRequest(root, dir, request.requestId).finally(() => this.visualCleanupExecutor.reconcile());
748
+ },
749
+ };
590
750
  }
591
751
  leaveDetail(release = true) {
592
752
  this.deckBrowserGeneration++;
@@ -595,6 +755,7 @@ export class InboxController {
595
755
  this.deckBrowserStarting = false;
596
756
  this.adapter?.close();
597
757
  this.adapter = undefined;
758
+ this.visualCleanupExecutor.reconcile();
598
759
  this.activeDeck = undefined;
599
760
  this.selectedWatcher?.close();
600
761
  this.selectedWatcher = undefined;
@@ -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 };
@@ -9,6 +9,7 @@ export interface InboxRootRegistration {
9
9
  owner: string;
10
10
  handler?: CompletionHandler;
11
11
  followUpHandler?: CompletionHandler;
12
+ visualHandler?: CompletionHandler;
12
13
  }
13
14
  export interface InboxRootStatus extends InboxRootRegistration {
14
15
  available: boolean;
@@ -18,6 +19,7 @@ export interface RegisterInboxRootOptions {
18
19
  owner: string;
19
20
  handler?: CompletionHandler;
20
21
  followUpHandler?: CompletionHandler;
22
+ visualHandler?: CompletionHandler;
21
23
  }
22
24
  export declare function inboxRootsDirectory(): string;
23
25
  /** Create/canonicalize a root and claim its user-scoped registration. */
@@ -15,8 +15,8 @@ catch {
15
15
  function validateHandler(handler) {
16
16
  if (handler === undefined)
17
17
  return undefined;
18
- if (!handler.command || !Array.isArray(handler.args) || !handler.args.every((arg) => typeof arg === 'string'))
19
- throw new Error('completion handler requires a command and string args');
18
+ if (typeof handler.command !== 'string' || handler.command.trim() === '' || !Array.isArray(handler.args) || !handler.args.every((arg) => typeof arg === 'string'))
19
+ throw new Error('handler requires a non-empty command and string args');
20
20
  return { command: handler.command, args: [...handler.args] };
21
21
  }
22
22
  function validateRegistration(raw) {
@@ -30,6 +30,7 @@ function validateRegistration(raw) {
30
30
  schema: 'humanloop.inbox-root/v1', root: value.root, owner: value.owner,
31
31
  handler: value.handler === undefined ? undefined : validateHandler(value.handler),
32
32
  followUpHandler: value.followUpHandler === undefined ? undefined : validateHandler(value.followUpHandler),
33
+ visualHandler: value.visualHandler === undefined ? undefined : validateHandler(value.visualHandler),
33
34
  };
34
35
  }
35
36
  catch {
@@ -50,7 +51,7 @@ export function registerInboxRoot(opts) {
50
51
  throw new Error(`inbox root is already owned by ${existing.owner}`);
51
52
  if (existing !== null && existing.root !== root)
52
53
  throw new Error('inbox root registry hash collision');
53
- const registration = { schema: 'humanloop.inbox-root/v1', root, owner: opts.owner, handler: validateHandler(opts.handler), followUpHandler: validateHandler(opts.followUpHandler) };
54
+ const registration = { schema: 'humanloop.inbox-root/v1', root, owner: opts.owner, handler: validateHandler(opts.handler), followUpHandler: validateHandler(opts.followUpHandler), visualHandler: validateHandler(opts.visualHandler) };
54
55
  atomicWriteJson(path, registration);
55
56
  chmodSync(path, 0o600);
56
57
  return registration;
@@ -1,6 +1,11 @@
1
1
  import type { Deck, DeckTicketResult, FeedbackResult, ReviewDescriptor, TicketResult } from '../types.js';
2
2
  /** Strict decoder for the only canonical final marker. */
3
3
  export declare function readTicketResult(dirOrResponsePath: string): TicketResult | null;
4
+ /** Canonical registered-root/direct-child containment shared by host protocols. */
5
+ export declare function requireCanonicalTicket(root: string, dir: string): {
6
+ root: string;
7
+ dir: string;
8
+ };
4
9
  export interface SubmitDeckOptions {
5
10
  root: string;
6
11
  id: string;
@@ -1,12 +1,13 @@
1
- import { existsSync, linkSync, mkdirSync, readFileSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs';
2
2
  import { basename, dirname, isAbsolute, resolve } from 'node:path';
3
3
  import { z } from 'zod';
4
4
  import { buildSummary } from '../summary.js';
5
- import { clearProgress, claimPath, deckPath, deliveryErrorPath, deliveryPath, followupRequestPath, followupResultPath, progressPath, responsePath, reviewPath } from './convention.js';
5
+ import { clearProgress, claimPath, deckPath, deliveryErrorPath, deliveryPath, followupRequestPath, followupResultPath, progressPath, publishJsonExclusive, responsePath, reviewPath, visualsDir } from './convention.js';
6
6
  import { validateDeck, validateReviewDescriptor, validateReviewProjection, resolveDeckBodyPaths } from './deck-schema.js';
7
7
  import { registeredInboxRoot } from './registry.js';
8
8
  import { readTicketClaim, releaseClaimLocked, withTicketLock } from './claim.js';
9
9
  import { dispatchCompletion } from './completion.js';
10
+ import { cancelVisualRequestsForTicket } from './visual.js';
10
11
  const ticketId = z.string().regex(/^[A-Za-z0-9_-]+$/).min(1).max(128);
11
12
  const responseSchema = z.object({ id: z.string().min(1), selectedOptionId: z.string().optional(), selectedOptionIds: z.array(z.string()).optional(), freetext: z.string().optional(), optionComments: z.record(z.string(), z.string()).optional() }).strict();
12
13
  const feedbackCommentSchema = z.object({ id: z.string().min(1), line: z.number().int().positive(), endLine: z.number().int().positive(), quote: z.string().optional(), colStart: z.number().int().nonnegative().optional(), colEnd: z.number().int().nonnegative().optional(), lineText: z.string(), comment: z.string().min(1), createdAt: z.string().min(1) }).strict();
@@ -70,7 +71,7 @@ function ticketDir(root, id) {
70
71
  function discardCreatedTicket(dir, created) { if (created)
71
72
  rmSync(dir, { recursive: true, force: true }); }
72
73
  function hasTicketProtocolState(dir) {
73
- return [deckPath(dir), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir), deliveryPath(dir), deliveryErrorPath(dir), followupRequestPath(dir), followupResultPath(dir)].some(existsSync);
74
+ return [deckPath(dir), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir), deliveryPath(dir), deliveryErrorPath(dir), followupRequestPath(dir), followupResultPath(dir), visualsDir(dir)].some(existsSync);
74
75
  }
75
76
  function requireRegisteredTicket(dir) {
76
77
  const canonical = realpathSync(dir);
@@ -82,21 +83,17 @@ function requireRegisteredTicket(dir) {
82
83
  throw new Error('ticket has no request descriptor');
83
84
  return { root, dir: canonical };
84
85
  }
86
+ /** Canonical registered-root/direct-child containment shared by host protocols. */
87
+ export function requireCanonicalTicket(root, dir) {
88
+ const registration = registeredInboxRoot(root);
89
+ const ticket = requireRegisteredTicket(dir);
90
+ if (registration === null || ticket.root !== registration.root)
91
+ throw new Error('ticket is not a canonical direct child of the registered root');
92
+ return ticket;
93
+ }
85
94
  function publishRequest(path, value) {
86
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
87
- const temp = `${path}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
88
- writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
89
- try {
90
- linkSync(temp, path);
91
- }
92
- catch (error) {
93
- if (error.code === 'EEXIST')
94
- throw new Error(`ticket request already exists: ${path}`);
95
- throw error;
96
- }
97
- finally {
98
- unlinkSync(temp);
99
- }
95
+ if (!publishJsonExclusive(path, value))
96
+ throw new Error(`ticket request already exists: ${path}`);
100
97
  }
101
98
  export function submitDeck(opts) {
102
99
  // Validate shape before mutating caller-owned interaction directories.
@@ -135,21 +132,7 @@ export function submitReview(opts) {
135
132
  }
136
133
  }
137
134
  function exclusiveResult(dir, result) {
138
- const path = responsePath(dir);
139
- const temp = `${path}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
140
- writeFileSync(temp, `${JSON.stringify(result, null, 2)}\n`, { mode: 0o600 });
141
- try {
142
- linkSync(temp, path);
143
- return true;
144
- }
145
- catch (error) {
146
- if (error.code === 'EEXIST')
147
- return false;
148
- throw error;
149
- }
150
- finally {
151
- unlinkSync(temp);
152
- }
135
+ return publishJsonExclusive(responsePath(dir), result);
153
136
  }
154
137
  function requireDeck(dir) { return validateDeck(JSON.parse(readFileSync(deckPath(dir), 'utf8'))); }
155
138
  function requireReview(dir) { return validateReviewDescriptor(JSON.parse(readFileSync(reviewPath(dir), 'utf8'))); }
@@ -191,6 +174,10 @@ export function finalizeDeck(dir, responses, claimToken, completedAt = new Date(
191
174
  requireClaimOwnership(ticket.dir, claimToken);
192
175
  const deck = requireDeck(ticket.dir);
193
176
  const parsedResponses = validateDeckResponses(deck, responses);
177
+ // A valid primary result ends its panel generation before the canonical
178
+ // response can cross the owner boundary. The protocol persists cancellation
179
+ // first; its independent cleanup executor owns eventual handler delivery.
180
+ void cancelVisualRequestsForTicket(ticket.root, ticket.dir);
194
181
  const result = { schema: 'humanloop.response/v2', kind: 'deck', responses: parsedResponses, summary: buildSummary(deck, parsedResponses), completedAt };
195
182
  const won = exclusiveResult(ticket.dir, result);
196
183
  clearOwnedWork(ticket.dir, claimToken);