@crouton-kit/humanloop 0.3.33 → 0.3.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/api.d.ts +4 -8
  2. package/dist/api.js +6 -29
  3. package/dist/browser/server.d.ts +3 -3
  4. package/dist/browser/server.js +22 -25
  5. package/dist/cli.js +131 -1114
  6. package/dist/editor/feedback.d.ts +9 -0
  7. package/dist/editor/feedback.js +23 -0
  8. package/dist/editor/review.d.ts +12 -30
  9. package/dist/editor/review.js +109 -119
  10. package/dist/inbox/claim.d.ts +23 -0
  11. package/dist/inbox/claim.js +67 -0
  12. package/dist/inbox/completion.d.ts +4 -0
  13. package/dist/inbox/completion.js +107 -0
  14. package/dist/inbox/controller.d.ts +74 -0
  15. package/dist/inbox/controller.js +457 -0
  16. package/dist/inbox/convention.d.ts +15 -10
  17. package/dist/inbox/convention.js +149 -79
  18. package/dist/inbox/deck-adapter.d.ts +27 -0
  19. package/dist/inbox/deck-adapter.js +57 -0
  20. package/dist/inbox/deck-schema.d.ts +18 -14
  21. package/dist/inbox/deck-schema.js +59 -117
  22. package/dist/inbox/layout.d.ts +8 -0
  23. package/dist/inbox/layout.js +9 -0
  24. package/dist/inbox/registry.d.ts +29 -0
  25. package/dist/inbox/registry.js +97 -0
  26. package/dist/inbox/review-adapter.d.ts +24 -0
  27. package/dist/inbox/review-adapter.js +57 -0
  28. package/dist/inbox/scan.d.ts +3 -2
  29. package/dist/inbox/scan.js +71 -43
  30. package/dist/inbox/tickets.d.ts +63 -0
  31. package/dist/inbox/tickets.js +247 -0
  32. package/dist/inbox/tui.d.ts +3 -6
  33. package/dist/inbox/tui.js +24 -112
  34. package/dist/index.d.ts +12 -3
  35. package/dist/index.js +8 -2
  36. package/dist/render/termrender.d.ts +5 -0
  37. package/dist/render/termrender.js +63 -4
  38. package/dist/summary.d.ts +2 -3
  39. package/dist/summary.js +2 -3
  40. package/dist/surfaces/inbox-popup.d.ts +2 -0
  41. package/dist/surfaces/inbox-popup.js +32 -0
  42. package/dist/tui/app.js +13 -0
  43. package/dist/tui/tmux.d.ts +30 -7
  44. package/dist/tui/tmux.js +190 -66
  45. package/dist/types.d.ts +68 -7
  46. package/package.json +2 -2
@@ -1,76 +1,36 @@
1
- import { existsSync, statSync, writeFileSync, renameSync, readFileSync, unlinkSync, mkdirSync } from 'fs';
2
- import { dirname } from 'path';
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
3
4
  import { buildSummary } from '../summary.js';
4
- // ── Path helpers ──────────────────────────────────────────────────────────────
5
- export function deckPath(dir) {
6
- return `${dir}/deck.json`;
7
- }
8
- export function responsePath(dir) {
9
- return `${dir}/response.json`;
10
- }
11
- export function progressPath(dir) {
12
- return `${dir}/progress.json`;
13
- }
14
- export function visualsDir(dir) {
15
- return `${dir}/visuals`;
16
- }
17
- export function visualMdPath(dir, id) {
18
- return `${dir}/visuals/${id}.md`;
19
- }
20
- export function visualAnsiPath(dir, id) {
21
- return `${dir}/visuals/${id}.ansi`;
22
- }
5
+ export function deckPath(dir) { return `${dir}/deck.json`; }
6
+ export function reviewPath(dir) { return `${dir}/review.json`; }
7
+ export function responsePath(dir) { return `${dir}/response.json`; }
8
+ export function progressPath(dir) { return `${dir}/progress.json`; }
9
+ export function claimPath(dir) { return `${dir}/claim.json`; }
10
+ export function deliveryPath(dir) { return `${dir}/delivery.json`; }
11
+ export function deliveryErrorPath(dir) { return `${dir}/delivery-error.json`; }
12
+ export function visualsDir(dir) { return `${dir}/visuals`; }
13
+ export function visualMdPath(dir, id) { return `${dir}/visuals/${id}.md`; }
14
+ export function visualAnsiPath(dir, id) { return `${dir}/visuals/${id}.ansi`; }
23
15
  export function interactionState(dir) {
24
- const hasDeck = existsSync(deckPath(dir));
25
- const hasResponse = existsSync(responsePath(dir));
26
- const hasProgress = existsSync(progressPath(dir));
27
- if (!hasDeck)
16
+ if (!existsSync(deckPath(dir)) && !existsSync(reviewPath(dir)))
28
17
  return 'missing';
29
- if (hasResponse)
18
+ if (existsSync(responsePath(dir)))
30
19
  return 'resolved';
31
- if (hasProgress)
32
- return 'in-progress';
33
- return 'pending';
34
- }
35
- export function isResolved(dir) {
36
- return existsSync(responsePath(dir));
37
- }
38
- /** Returns true if a live resolver owns this dir (progress.json mtime < 300s). */
39
- export function isClaimed(dir) {
40
- const p = progressPath(dir);
41
- if (!existsSync(p))
42
- return false;
43
- try {
44
- const { mtimeMs } = statSync(p);
45
- return Date.now() - mtimeMs < 300_000;
46
- }
47
- catch {
48
- return false;
49
- }
20
+ return existsSync(claimPath(dir)) ? 'claimed' : 'pending';
50
21
  }
51
- // ── Canvas-node attribution ─────────────────────────────────────────
52
- /**
53
- * Stamp the originating canvas node id onto a deck's `source` so per-node
54
- * attention scoping (crouter's nav chrome) can attribute the ask to the node
55
- * that raised it rather than every sibling node sharing the same cwd.
56
- *
57
- * No-op when not inside a canvas node (CRTR_NODE_ID unset) or when the deck
58
- * already carries a nodeId. Mutates `deck` in place.
59
- */
22
+ export function isResolved(dir) { return existsSync(responsePath(dir)); }
23
+ export function isClaimed(dir) { return existsSync(claimPath(dir)); }
60
24
  export function stampCanvasNode(deck) {
61
25
  const id = process.env['CRTR_NODE_ID'];
62
- if (id === undefined || id.trim() === '')
63
- return;
64
- if (deck.source?.nodeId != null && deck.source.nodeId !== '')
26
+ if (id === undefined || id.trim() === '' || deck.source?.nodeId)
65
27
  return;
66
28
  deck.source = { ...(deck.source ?? {}), nodeId: id };
67
29
  }
68
- // ── Atomic I/O ────────────────────────────────────────────────────────────────
69
30
  export function atomicWriteJson(path, value) {
70
- const payload = JSON.stringify(value, null, 2);
71
- const tmp = `${path}.tmp`;
31
+ const tmp = `${path}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
72
32
  mkdirSync(dirname(path), { recursive: true });
73
- writeFileSync(tmp, payload);
33
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
74
34
  renameSync(tmp, path);
75
35
  }
76
36
  export function readJson(path) {
@@ -81,31 +41,141 @@ export function readJson(path) {
81
41
  return null;
82
42
  }
83
43
  }
84
- // ── High-level write helpers ──────────────────────────────────────────────────
44
+ function pause(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
45
+ // The holder's identity lives in the NAME of its single marker file. The marker's
46
+ // mtime is the heartbeat and its presence is ownership, so the observed token, the
47
+ // staleness clock, and the reclaim gate are one atomic fact.
48
+ const MARKER_PREFIX = 'owner.';
49
+ function markerPath(path, token) { return `${path}/${MARKER_PREFIX}${token}`; }
50
+ function observedMarkerToken(path) {
51
+ try {
52
+ return readdirSync(path).find((entry) => entry.startsWith(MARKER_PREFIX))?.slice(MARKER_PREFIX.length) ?? null;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ function ownsLock(lock) { return existsSync(markerPath(lock.path, lock.token)); }
59
+ function releaseDirectoryLock(lock) { if (ownsLock(lock))
60
+ rmSync(lock.path, { recursive: true, force: true }); }
61
+ function lockAge(path, token) {
62
+ const target = token !== null ? markerPath(path, token) : path;
63
+ try {
64
+ return Date.now() - statSync(existsSync(target) ? target : path).mtimeMs;
65
+ }
66
+ catch {
67
+ return 0;
68
+ }
69
+ }
70
+ // Reclamation is bound to the exact instance it observed: it unlinks ONLY that
71
+ // token's marker, then removes the now-empty directory with an empty-guarded
72
+ // rmdir. A successor lock holds a different random token (a different marker
73
+ // name), so a lagging reclaimer that saw the old stale lock can never strip a
74
+ // live successor — its unlink targets a name that no longer exists.
75
+ function reclaimIfStale(path, staleMs) {
76
+ const token = observedMarkerToken(path);
77
+ if (lockAge(path, token) <= staleMs)
78
+ return;
79
+ if (token !== null) {
80
+ try {
81
+ unlinkSync(markerPath(path, token));
82
+ }
83
+ catch (error) {
84
+ if (error.code === 'ENOENT')
85
+ return;
86
+ throw error;
87
+ }
88
+ }
89
+ try {
90
+ rmdirSync(path);
91
+ }
92
+ catch (error) {
93
+ const code = error.code;
94
+ if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST')
95
+ throw error;
96
+ }
97
+ }
98
+ function tryAcquireDirectoryLock(path, staleMs) {
99
+ const token = randomUUID();
100
+ try {
101
+ mkdirSync(path, { mode: 0o700 });
102
+ writeFileSync(markerPath(path, token), '', { flag: 'wx', mode: 0o600 });
103
+ return { path, token };
104
+ }
105
+ catch (error) {
106
+ if (error.code !== 'EEXIST')
107
+ throw error;
108
+ reclaimIfStale(path, staleMs);
109
+ return null;
110
+ }
111
+ }
112
+ function acquireDirectoryLock(path, staleMs, timeoutMs) {
113
+ const startedAt = Date.now();
114
+ while (true) {
115
+ const lock = tryAcquireDirectoryLock(path, staleMs);
116
+ if (lock !== null)
117
+ return lock;
118
+ if (Date.now() - startedAt >= timeoutMs)
119
+ throw new Error('exclusive operation lock acquisition timed out');
120
+ pause(5);
121
+ }
122
+ }
123
+ async function acquireDirectoryLockAsync(path, staleMs, timeoutMs) {
124
+ const startedAt = Date.now();
125
+ while (true) {
126
+ const lock = tryAcquireDirectoryLock(path, staleMs);
127
+ if (lock !== null)
128
+ return lock;
129
+ if (Date.now() - startedAt >= timeoutMs)
130
+ throw new Error('exclusive operation lock acquisition timed out');
131
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 10));
132
+ }
133
+ }
134
+ /** Runs a short filesystem transition under a token-checked, crash-reclaimable directory lock. */
135
+ export function withExclusiveDirectoryLock(path, operation, options = {}) {
136
+ const lock = acquireDirectoryLock(path, options.staleMs ?? 30_000, options.timeoutMs ?? 5_000);
137
+ try {
138
+ return operation();
139
+ }
140
+ finally {
141
+ releaseDirectoryLock(lock);
142
+ }
143
+ }
144
+ /** Async counterpart heartbeats while its operation runs, so a valid long handler is never stolen. */
145
+ export async function withExclusiveDirectoryLockAsync(path, operation, options = {}) {
146
+ const staleMs = options.staleMs ?? 35_000;
147
+ const lock = await acquireDirectoryLockAsync(path, staleMs, options.timeoutMs ?? staleMs + 5_000);
148
+ const heartbeat = setInterval(() => {
149
+ if (ownsLock(lock)) {
150
+ try {
151
+ utimesSync(markerPath(lock.path, lock.token), new Date(), new Date());
152
+ }
153
+ catch { /* a reclaimed lock is no longer ours */ }
154
+ }
155
+ }, 1_000);
156
+ try {
157
+ return await operation();
158
+ }
159
+ finally {
160
+ clearInterval(heartbeat);
161
+ releaseDirectoryLock(lock);
162
+ }
163
+ }
164
+ // Kept for the existing panel until H2 routes all finalization through tickets.ts.
85
165
  export function writeResponse(dir, responses, completedAt, deck) {
86
- const p = responsePath(dir);
87
- // Persist the deterministic summary alongside the raw responses so
88
- // `hl job result` can return a populated summary without re-deriving it (or
89
- // silently emitting ''). When the deck is known at write time we compute it
90
- // here; the deck is deterministic input so this never diverges from ask()'s
91
- // envelope summary.
92
- const summary = deck !== undefined ? buildSummary(deck, responses) : '';
93
- atomicWriteJson(p, { responses, completedAt, summary });
94
- return p;
166
+ const summary = deck === undefined ? '' : buildSummary(deck, responses);
167
+ atomicWriteJson(responsePath(dir), { schema: 'humanloop.response/v2', kind: 'deck', responses, summary, completedAt });
168
+ return responsePath(dir);
95
169
  }
96
170
  export function writeProgress(dir, responses) {
97
- atomicWriteJson(progressPath(dir), {
98
- partial: true,
99
- responses,
100
- savedAt: new Date().toISOString(),
101
- });
171
+ atomicWriteJson(progressPath(dir), { kind: 'deck', responses, savedAt: new Date().toISOString() });
102
172
  }
103
173
  export function clearProgress(dir) {
104
174
  try {
105
175
  unlinkSync(progressPath(dir));
106
176
  }
107
- catch (err) {
108
- if (err.code !== 'ENOENT')
109
- throw err;
177
+ catch (error) {
178
+ if (error.code !== 'ENOENT')
179
+ throw error;
110
180
  }
111
181
  }
@@ -0,0 +1,27 @@
1
+ import type { Deck, InteractionResponse } from '../types.js';
2
+ import type { Key } from '../tui/terminal.js';
3
+ export interface DeckAdapterOptions {
4
+ dir: string;
5
+ deck: Deck;
6
+ cols: number;
7
+ rows: number;
8
+ onProgress?: (responses: InteractionResponse[]) => void;
9
+ onComplete: (responses: InteractionResponse[]) => void;
10
+ onBack: () => void;
11
+ onDirty: () => void;
12
+ }
13
+ /** Embeds the single deck renderer in a controller-owned rectangle. */
14
+ export declare class DeckAdapter {
15
+ private readonly opts;
16
+ private panel;
17
+ private responses;
18
+ constructor(opts: DeckAdapterOptions);
19
+ render(): string[];
20
+ resize(cols: number, rows: number): string[];
21
+ inputBuffer(): string | undefined;
22
+ canAcceptHostKeys(): boolean;
23
+ handleKey(input: string, key: Key): void;
24
+ /** Fresh descriptor reads preserve mounted answers for interaction ids still present. */
25
+ reload(): void;
26
+ close(): void;
27
+ }
@@ -0,0 +1,57 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { deckPath, progressPath, readJson } from './convention.js';
3
+ import { validateDeck } from './deck-schema.js';
4
+ import { mountPanel } from '../tui/app.js';
5
+ /** Embeds the single deck renderer in a controller-owned rectangle. */
6
+ export class DeckAdapter {
7
+ opts;
8
+ panel;
9
+ responses = [];
10
+ constructor(opts) {
11
+ this.opts = opts;
12
+ this.responses = initialResponses(opts.deck, opts.dir);
13
+ this.panel = mountPanel({
14
+ deck: opts.deck,
15
+ progressPath: progressPath(opts.dir),
16
+ cols: opts.cols,
17
+ rows: opts.rows,
18
+ onProgress: (responses) => { this.responses = responses; opts.onProgress?.(responses); },
19
+ onComplete: opts.onComplete,
20
+ onExit: () => opts.onComplete(this.responses),
21
+ onDirty: opts.onDirty,
22
+ });
23
+ }
24
+ render() { return this.panel.render(); }
25
+ resize(cols, rows) { return this.panel.handleResize(cols, rows); }
26
+ inputBuffer() { return this.panel.getInputBuffer(); }
27
+ canAcceptHostKeys() { return this.panel.canAcceptHostKeys(); }
28
+ handleKey(input, key) {
29
+ if (key.escape && this.panel.atDeckTop()) {
30
+ this.opts.onBack();
31
+ return;
32
+ }
33
+ this.panel.handleKey(input, key);
34
+ }
35
+ /** Fresh descriptor reads preserve mounted answers for interaction ids still present. */
36
+ reload() {
37
+ try {
38
+ this.panel.loadDeck(validateDeck(JSON.parse(readFileSync(deckPath(this.opts.dir), 'utf8'))), { progressPath: progressPath(this.opts.dir) });
39
+ this.opts.onDirty();
40
+ }
41
+ catch {
42
+ // An incomplete external rewrite is not a new deck; retain the current editor.
43
+ }
44
+ }
45
+ close() { this.panel.unmount(); }
46
+ }
47
+ function initialResponses(deck, dir) {
48
+ const saved = readJson(progressPath(dir))?.responses;
49
+ if (Array.isArray(saved))
50
+ return saved;
51
+ return deck.interactions.flatMap((interaction) => {
52
+ const answer = interaction.preAnswered;
53
+ if (answer === undefined)
54
+ return [];
55
+ return [{ id: interaction.id, ...(answer.selectedOptionId === undefined ? {} : { selectedOptionId: answer.selectedOptionId }), ...(answer.selectedOptionIds === undefined ? {} : { selectedOptionIds: [...answer.selectedOptionIds] }), ...(answer.freetext === undefined ? {} : { freetext: answer.freetext }) }];
56
+ });
57
+ }
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import type { Deck } from '../types.js';
2
+ import type { Deck, ReviewDescriptor } from '../types.js';
3
3
  export declare const interactionOptionSchema: z.ZodObject<{
4
4
  id: z.ZodString;
5
5
  label: z.ZodString;
@@ -48,18 +48,22 @@ export declare const deckSchema: z.ZodObject<{
48
48
  }, z.core.$strip>>;
49
49
  }, z.core.$strip>>;
50
50
  }, z.core.$strip>;
51
- /**
52
- * The ONE canonical `bodyPath` → `body` normalization boundary. Resolves every
53
- * interaction's `bodyPath` (relative to `dir`, the interaction directory) into
54
- * `body` and strips `bodyPath` from the result.
55
- *
56
- * Call this once, right before a deck is (re)written to `<dir>/deck.json` —
57
- * `hl deck ask`, `hl deck update`, and the public `ask()` API all do — so
58
- * every reader downstream (the terminal TUI's render + its live-reload
59
- * poller, the browser server's `/api/interaction`) only ever sees a plain
60
- * `body` and never has to special-case `bodyPath` itself. `parseDeck` (below)
61
- * reuses this same function when reading a deck straight off disk.
62
- */
51
+ export declare const reviewDescriptorSchema: z.ZodObject<{
52
+ schema: z.ZodLiteral<"humanloop.review/v1">;
53
+ file: z.ZodString;
54
+ output: z.ZodString;
55
+ title: z.ZodString;
56
+ source: z.ZodObject<{
57
+ sessionName: z.ZodOptional<z.ZodString>;
58
+ askedBy: z.ZodOptional<z.ZodString>;
59
+ blockedSince: z.ZodOptional<z.ZodString>;
60
+ nodeId: z.ZodOptional<z.ZodString>;
61
+ }, z.core.$strip>;
62
+ blockedSince: z.ZodString;
63
+ }, z.core.$strict>;
63
64
  export declare function resolveDeckBodyPaths(deck: Deck, dir: string): Deck;
64
- export declare function parseDeck(deckPath: string): Deck;
65
+ export declare function parseDeck(path: string): Deck;
65
66
  export declare function validateDeck(parsed: unknown): Deck;
67
+ export declare function validateReviewDescriptor(parsed: unknown): ReviewDescriptor;
68
+ /** Canonicalize and authorize the one review projection boundary before every write. */
69
+ export declare function validateReviewProjection(dir: string, parsed: unknown): ReviewDescriptor;
@@ -1,143 +1,85 @@
1
1
  import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
2
- import { dirname, resolve, sep } from 'node:path';
2
+ import { basename, dirname, isAbsolute, resolve, sep } from 'node:path';
3
+ import { claimPath, deckPath, deliveryErrorPath, deliveryPath, progressPath, responsePath, reviewPath } from './convention.js';
3
4
  import { z } from 'zod';
4
5
  import { INTERACTION_KINDS } from '../types.js';
5
6
  import { checkMarkdown } from '../render/termrender.js';
6
- // ── zod v4 building blocks ────────────────────────────────────────────────────
7
- // v4 notes: .nonempty() .min(1); error messages use {error: 'string'} per check.
8
- export const interactionOptionSchema = z.object({
9
- id: z.string().min(1),
10
- label: z.string().min(1),
11
- description: z.string().optional(),
12
- // No author-settable `shortcut`: option shortcuts are auto-assigned by the
13
- // TUI (assignShortcuts). zod strips unknown keys, so any author-supplied
14
- // `shortcut` is silently dropped here — this is the enforcement boundary that
15
- // stops a deck from shadowing a reserved key (e.g. `c` = comment).
16
- });
17
- export const preAnswerSchema = z.object({
18
- selectedOptionId: z.string().optional(),
19
- selectedOptionIds: z.array(z.string()).optional(),
20
- freetext: z.string().optional(),
21
- label: z.string().optional(),
22
- });
7
+ export const interactionOptionSchema = z.object({ id: z.string().min(1), label: z.string().min(1), description: z.string().optional() });
8
+ export const preAnswerSchema = z.object({ selectedOptionId: z.string().optional(), selectedOptionIds: z.array(z.string()).optional(), freetext: z.string().optional(), label: z.string().optional() });
23
9
  const interactionSchema = z.object({
24
10
  id: z.string().regex(/^[A-Za-z0-9_-]+$/, { error: 'interaction id must match /^[A-Za-z0-9_-]+$/' }).min(1).max(64),
25
- title: z.string().min(1, { error: 'title must be non-empty' }),
26
- subtitle: z.string().min(1, { error: 'subtitle must be non-empty when present' }).optional(),
27
- body: z.string().optional(),
28
- bodyPath: z.string().optional(),
29
- options: z.array(interactionOptionSchema),
30
- multiSelect: z.boolean().optional(),
31
- allowFreetext: z.boolean().optional(),
32
- freetextLabel: z.string().optional(),
33
- kind: z.enum(INTERACTION_KINDS).optional(),
34
- preAnswered: preAnswerSchema.optional(),
35
- });
36
- const deckSourceSchema = z.object({
37
- sessionName: z.string().optional(),
38
- askedBy: z.string().optional(),
39
- blockedSince: z.string().optional(),
40
- nodeId: z.string().optional(),
11
+ title: z.string().min(1), subtitle: z.string().min(1).optional(), body: z.string().optional(), bodyPath: z.string().optional(),
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(),
41
13
  });
42
- export const deckSchema = z.object({
43
- title: z.string().optional(),
44
- source: deckSourceSchema.optional(),
45
- interactions: z.array(interactionSchema).min(1, { error: 'interactions[] must be non-empty' }),
46
- }).superRefine((input, ctx) => {
47
- const seen = new Map();
48
- for (let i = 0; i < input.interactions.length; i++) {
49
- const interaction = input.interactions[i];
50
- if (interaction.body !== undefined && interaction.bodyPath !== undefined) {
51
- ctx.addIssue({
52
- code: 'custom',
53
- message: 'body and bodyPath are mutually exclusive',
54
- path: ['interactions', i],
55
- });
56
- }
57
- const prev = seen.get(interaction.id);
58
- if (prev !== undefined) {
59
- ctx.addIssue({
60
- code: 'custom',
61
- message: `duplicate interaction id "${interaction.id}" at indices ${prev} and ${i}`,
62
- path: ['interactions', i, 'id'],
63
- });
64
- }
65
- seen.set(interaction.id, i);
66
- }
14
+ const deckSourceSchema = z.object({ sessionName: z.string().optional(), askedBy: z.string().optional(), blockedSince: z.string().optional(), nodeId: z.string().optional() });
15
+ export const deckSchema = z.object({ title: z.string().optional(), source: deckSourceSchema.optional(), interactions: z.array(interactionSchema).min(1) }).superRefine((input, ctx) => {
16
+ const seen = new Set();
17
+ input.interactions.forEach((interaction, index) => {
18
+ if (interaction.body !== undefined && interaction.bodyPath !== undefined)
19
+ ctx.addIssue({ code: 'custom', message: 'body and bodyPath are mutually exclusive', path: ['interactions', index] });
20
+ if (seen.has(interaction.id))
21
+ ctx.addIssue({ code: 'custom', message: `duplicate interaction id "${interaction.id}"`, path: ['interactions', index, 'id'] });
22
+ seen.add(interaction.id);
23
+ });
67
24
  });
68
- // ── C2 bodyPath defense + inlining ────────────────────────────────────────────
69
- /**
70
- * Read `bodyPath` (relative to `dir`, the interaction directory a deck.json
71
- * lives/will live in) with the traversal/symlink defenses a file read off an
72
- * agent-supplied relative path needs.
73
- */
25
+ const reviewSourceSchema = deckSourceSchema;
26
+ export const reviewDescriptorSchema = z.object({
27
+ schema: z.literal('humanloop.review/v1'),
28
+ file: z.string().min(1).refine(isAbsolute, 'file must be absolute'),
29
+ output: z.string().min(1).refine(isAbsolute, 'output must be absolute'),
30
+ title: z.string().min(1),
31
+ source: reviewSourceSchema,
32
+ blockedSince: z.string().datetime({ offset: true }),
33
+ }).strict();
74
34
  function readBodyPathFile(dir, bodyPath) {
75
35
  const joined = resolve(dir, bodyPath);
76
- // STEP 1: existence + lstat BEFORE realpath to catch symlinks and directories.
77
- if (!existsSync(joined)) {
78
- throw new Error(`bodyPath does not exist: '${bodyPath}' (resolved against deck dir '${dir}'). bodyPath is interpreted relative to the deck JSON's directory; place the body file there and use a relative path (e.g. "completion-summary.md").`);
79
- }
80
- const stat = lstatSync(joined);
81
- if (!stat.isFile()) {
82
- // Catches symlinks, directories, FIFOs — lstat does not follow symlinks.
83
- throw new Error(`bodyPath must be a regular file (not a symlink, directory, or special file): ${bodyPath}`);
84
- }
85
- // STEP 2: realpath both sides, prefix-check (defense-in-depth for .. traversal).
86
- // realpathSync is safe here: lstat already confirmed the path exists.
36
+ if (!existsSync(joined))
37
+ throw new Error(`bodyPath does not exist: '${bodyPath}'`);
38
+ if (!lstatSync(joined).isFile())
39
+ throw new Error(`bodyPath must be a regular file: ${bodyPath}`);
87
40
  const realResolved = realpathSync(joined);
88
41
  const realDeckDir = realpathSync(dir);
89
- const prefix = realDeckDir + sep;
90
- if (realResolved !== realDeckDir && !realResolved.startsWith(prefix)) {
91
- throw new Error(`bodyPath '${bodyPath}' escapes the deck's directory ('${realDeckDir}'). bodyPath is resolved relative to the deck JSON file and must stay inside its directory (no '..', absolute paths pointing elsewhere, or symlinks out). Fix: write the deck JSON next to the body file (e.g. both inside $SISYPHUS_SESSION_DIR/context/) and use a relative path like "completion-summary.md".`);
92
- }
93
- // STEP 3: read. lstat confirmed regular file; realpath confirmed in-tree.
94
- return readFileSync(joined, 'utf-8');
42
+ if (!realResolved.startsWith(realDeckDir + sep))
43
+ throw new Error(`bodyPath '${bodyPath}' escapes the deck directory`);
44
+ return readFileSync(joined, 'utf8');
95
45
  }
96
- /**
97
- * The ONE canonical `bodyPath` → `body` normalization boundary. Resolves every
98
- * interaction's `bodyPath` (relative to `dir`, the interaction directory) into
99
- * `body` and strips `bodyPath` from the result.
100
- *
101
- * Call this once, right before a deck is (re)written to `<dir>/deck.json` —
102
- * `hl deck ask`, `hl deck update`, and the public `ask()` API all do — so
103
- * every reader downstream (the terminal TUI's render + its live-reload
104
- * poller, the browser server's `/api/interaction`) only ever sees a plain
105
- * `body` and never has to special-case `bodyPath` itself. `parseDeck` (below)
106
- * reuses this same function when reading a deck straight off disk.
107
- */
108
46
  export function resolveDeckBodyPaths(deck, dir) {
109
- const interactions = deck.interactions.map((interaction) => {
110
- if (interaction.bodyPath === undefined)
111
- return interaction;
112
- const body = readBodyPathFile(dir, interaction.bodyPath);
113
- // Drop bodyPath from persisted deck.json/decisions.json (recipe §1.8).
114
- const { bodyPath: _drop, ...rest } = interaction;
115
- return { ...rest, body };
116
- });
117
- return { ...deck, interactions };
47
+ return { ...deck, interactions: deck.interactions.map((interaction) => {
48
+ if (interaction.bodyPath === undefined)
49
+ return interaction;
50
+ const { bodyPath: _bodyPath, ...rest } = interaction;
51
+ return { ...rest, body: readBodyPathFile(dir, interaction.bodyPath) };
52
+ }) };
118
53
  }
119
- // ── public entry points ───────────────────────────────────────────────────────
120
- export function parseDeck(deckPath) {
121
- const raw = readFileSync(deckPath, 'utf-8');
122
- let json;
54
+ export function parseDeck(path) {
55
+ let raw;
123
56
  try {
124
- json = JSON.parse(raw);
57
+ raw = JSON.parse(readFileSync(path, 'utf8'));
125
58
  }
126
59
  catch {
127
60
  throw new Error('deck is not valid JSON');
128
61
  }
129
- const parsed = deckSchema.parse(json);
130
- const resolved = resolveDeckBodyPaths(parsed, dirname(deckPath));
131
- for (const interaction of resolved.interactions) {
62
+ const deck = resolveDeckBodyPaths(deckSchema.parse(raw), dirname(path));
63
+ for (const interaction of deck.interactions)
132
64
  if (interaction.body !== undefined) {
133
65
  const check = checkMarkdown(interaction.body);
134
- if (!check.ok) {
66
+ if (!check.ok)
135
67
  throw new Error(check.error);
136
- }
137
68
  }
138
- }
139
- return resolved;
69
+ return deck;
140
70
  }
141
- export function validateDeck(parsed) {
142
- return deckSchema.parse(parsed);
71
+ export function validateDeck(parsed) { return deckSchema.parse(parsed); }
72
+ export function validateReviewDescriptor(parsed) { return reviewDescriptorSchema.parse(parsed); }
73
+ /** Canonicalize and authorize the one review projection boundary before every write. */
74
+ export function validateReviewProjection(dir, parsed) {
75
+ const descriptor = validateReviewDescriptor(parsed);
76
+ if (!/\.md(?:own)?$/i.test(descriptor.file) || !existsSync(descriptor.file))
77
+ throw new Error('review file must be an existing absolute markdown file');
78
+ const file = realpathSync(descriptor.file);
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']);
81
+ const ownProtocolPaths = new Set([deckPath(dir), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir), deliveryPath(dir), deliveryErrorPath(dir)]);
82
+ if (output === file || reserved.has(basename(output)) || ownProtocolPaths.has(output))
83
+ throw new Error('review output must not alias the source or ticket protocol files');
84
+ return { ...descriptor, file, output };
143
85
  }
@@ -0,0 +1,8 @@
1
+ export interface InboxLayout {
2
+ mode: 'two-column' | 'list' | 'detail' | 'minimum';
3
+ listWidth: number;
4
+ detailWidth: number;
5
+ height: number;
6
+ }
7
+ /** Pure geometry for the centralized inbox surface. */
8
+ export declare function inboxLayout(cols: number, rows: number, screen?: 'list' | 'detail'): InboxLayout;
@@ -0,0 +1,9 @@
1
+ /** Pure geometry for the centralized inbox surface. */
2
+ export function inboxLayout(cols, rows, screen = 'list') {
3
+ if (cols < 60 || rows < 18)
4
+ return { mode: 'minimum', listWidth: cols, detailWidth: 0, height: rows };
5
+ if (cols < 96)
6
+ return { mode: screen, listWidth: cols, detailWidth: cols, height: rows };
7
+ const listWidth = Math.max(30, Math.min(44, Math.floor(cols / 3)));
8
+ return { mode: 'two-column', listWidth, detailWidth: cols - listWidth - 1, height: rows };
9
+ }
@@ -0,0 +1,29 @@
1
+ import type { CompletionEvent } from '../types.js';
2
+ export interface CompletionHandler {
3
+ command: string;
4
+ args: string[];
5
+ }
6
+ export interface InboxRootRegistration {
7
+ schema: 'humanloop.inbox-root/v1';
8
+ root: string;
9
+ owner: string;
10
+ handler?: CompletionHandler;
11
+ }
12
+ export interface InboxRootStatus extends InboxRootRegistration {
13
+ available: boolean;
14
+ }
15
+ export interface RegisterInboxRootOptions {
16
+ root: string;
17
+ owner: string;
18
+ handler?: CompletionHandler;
19
+ }
20
+ export declare function inboxRootsDirectory(): string;
21
+ /** Create/canonicalize a root and claim its user-scoped registration. */
22
+ export declare function registerInboxRoot(opts: RegisterInboxRootOptions): InboxRootRegistration;
23
+ /** Removes a matching available root through its real path, or an unavailable record by its stored canonical path. */
24
+ export declare function unregisterInboxRoot(root: string, owner: string): boolean;
25
+ export declare function listInboxRoots(): InboxRootStatus[];
26
+ export declare function registeredInboxRoot(root: string): InboxRootRegistration | null;
27
+ /** The managed SDK root is durable, user-scoped, and owned by humanloop. */
28
+ export declare function managedInboxRoot(): InboxRootRegistration;
29
+ export type CompletionHandlerEvent = CompletionEvent;