@crouton-kit/humanloop 0.3.33 → 0.3.34

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 (44) 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 +126 -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 +8 -30
  9. package/dist/editor/review.js +79 -118
  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 +100 -0
  14. package/dist/inbox/controller.d.ts +57 -0
  15. package/dist/inbox/controller.js +311 -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 +22 -0
  27. package/dist/inbox/review-adapter.js +56 -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/summary.d.ts +2 -3
  37. package/dist/summary.js +2 -3
  38. package/dist/surfaces/inbox-popup.d.ts +2 -0
  39. package/dist/surfaces/inbox-popup.js +32 -0
  40. package/dist/tui/app.js +13 -0
  41. package/dist/tui/tmux.d.ts +30 -7
  42. package/dist/tui/tmux.js +179 -66
  43. package/dist/types.d.ts +68 -7
  44. package/package.json +2 -2
@@ -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;
@@ -0,0 +1,97 @@
1
+ import { chmodSync, existsSync, mkdirSync, readdirSync, realpathSync, unlinkSync } from 'node:fs';
2
+ import { createHash } from 'node:crypto';
3
+ import { homedir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ import { atomicWriteJson, readJson, withExclusiveDirectoryLock } from './convention.js';
6
+ function stateHome() { return process.env['XDG_STATE_HOME'] || join(homedir(), '.local', 'state'); }
7
+ export function inboxRootsDirectory() { return join(stateHome(), 'humanloop', 'inbox-roots'); }
8
+ function recordPath(root) { return join(inboxRootsDirectory(), createHash('sha256').update(root).digest('hex')); }
9
+ function canonicalRoot(root) { try {
10
+ return realpathSync(root);
11
+ }
12
+ catch {
13
+ return resolve(root);
14
+ } }
15
+ function validateHandler(handler) {
16
+ if (handler === undefined)
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');
20
+ return { command: handler.command, args: [...handler.args] };
21
+ }
22
+ function validateRegistration(raw) {
23
+ if (typeof raw !== 'object' || raw === null)
24
+ return null;
25
+ const value = raw;
26
+ if (value.schema !== 'humanloop.inbox-root/v1' || typeof value.root !== 'string' || !value.root || typeof value.owner !== 'string' || !value.owner.trim())
27
+ return null;
28
+ try {
29
+ return { schema: 'humanloop.inbox-root/v1', root: value.root, owner: value.owner, handler: value.handler === undefined ? undefined : validateHandler(value.handler) };
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ /** Create/canonicalize a root and claim its user-scoped registration. */
36
+ export function registerInboxRoot(opts) {
37
+ if (!opts.owner.trim())
38
+ throw new Error('inbox root owner must be non-empty');
39
+ mkdirSync(resolve(opts.root), { recursive: true, mode: 0o700 });
40
+ const root = realpathSync(opts.root);
41
+ const path = recordPath(root);
42
+ mkdirSync(inboxRootsDirectory(), { recursive: true, mode: 0o700 });
43
+ return withExclusiveDirectoryLock(`${path}.lock`, () => {
44
+ const existing = validateRegistration(readJson(path));
45
+ if (existing !== null && existing.root === root && existing.owner !== opts.owner)
46
+ throw new Error(`inbox root is already owned by ${existing.owner}`);
47
+ if (existing !== null && existing.root !== root)
48
+ throw new Error('inbox root registry hash collision');
49
+ const registration = { schema: 'humanloop.inbox-root/v1', root, owner: opts.owner, handler: validateHandler(opts.handler) };
50
+ atomicWriteJson(path, registration);
51
+ chmodSync(path, 0o600);
52
+ return registration;
53
+ });
54
+ }
55
+ /** Removes a matching available root through its real path, or an unavailable record by its stored canonical path. */
56
+ export function unregisterInboxRoot(root, owner) {
57
+ const canonical = canonicalRoot(root);
58
+ const path = recordPath(canonical);
59
+ return withExclusiveDirectoryLock(`${path}.lock`, () => {
60
+ const existing = validateRegistration(readJson(path));
61
+ if (existing === null || existing.root !== canonical || existing.owner !== owner)
62
+ return false;
63
+ unlinkSync(path);
64
+ return true;
65
+ });
66
+ }
67
+ export function listInboxRoots() {
68
+ let files;
69
+ try {
70
+ files = readdirSync(inboxRootsDirectory());
71
+ }
72
+ catch {
73
+ return [];
74
+ }
75
+ const roots = [];
76
+ for (const file of files) {
77
+ const record = validateRegistration(readJson(join(inboxRootsDirectory(), file)));
78
+ if (record !== null)
79
+ roots.push({ ...record, available: existsSync(record.root) });
80
+ }
81
+ return roots.sort((a, b) => a.root.localeCompare(b.root));
82
+ }
83
+ export function registeredInboxRoot(root) {
84
+ let canonical;
85
+ try {
86
+ canonical = realpathSync(root);
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ const record = validateRegistration(readJson(recordPath(canonical)));
92
+ return record?.root === canonical ? record : null;
93
+ }
94
+ /** The managed SDK root is durable, user-scoped, and owned by humanloop. */
95
+ export function managedInboxRoot() {
96
+ return registerInboxRoot({ root: join(stateHome(), 'humanloop', 'inbox'), owner: 'humanloop' });
97
+ }
@@ -0,0 +1,22 @@
1
+ import { type TicketClaim } from './claim.js';
2
+ import { type ReviewOptions } from '../editor/review.js';
3
+ import type { ReviewDescriptor, TicketResult } from '../types.js';
4
+ export interface ReviewAdapterOptions {
5
+ dir: string;
6
+ descriptor: ReviewDescriptor;
7
+ claim: TicketClaim;
8
+ editor?: ReviewOptions['editor'];
9
+ onSubmitted?: (result: TicketResult) => Promise<void> | void;
10
+ }
11
+ /** Controller-owned native review child. It persists drafts but delegates the sole final write to tickets.ts. */
12
+ export declare class ReviewAdapter {
13
+ private readonly opts;
14
+ private readonly abortController;
15
+ private readonly heartbeat;
16
+ private running;
17
+ private stopped;
18
+ constructor(opts: ReviewAdapterOptions);
19
+ start(): Promise<TicketResult | null>;
20
+ /** Toggle, cancellation, and controller teardown all take this same path. */
21
+ stop(): Promise<void>;
22
+ }
@@ -0,0 +1,56 @@
1
+ import { completeReview } from './tickets.js';
2
+ import { heartbeatClaim, releaseClaim } from './claim.js';
3
+ import { progressPath } from './convention.js';
4
+ import { launchReview } from '../editor/review.js';
5
+ /** Controller-owned native review child. It persists drafts but delegates the sole final write to tickets.ts. */
6
+ export class ReviewAdapter {
7
+ opts;
8
+ abortController = new AbortController();
9
+ heartbeat;
10
+ running = null;
11
+ stopped = false;
12
+ constructor(opts) {
13
+ this.opts = opts;
14
+ this.heartbeat = setInterval(() => {
15
+ if (!this.stopped)
16
+ heartbeatClaim(this.opts.dir, this.opts.claim.token);
17
+ }, 10_000);
18
+ }
19
+ start() {
20
+ if (this.running !== null)
21
+ return this.running;
22
+ let canonical = null;
23
+ this.running = launchReview(this.opts.descriptor.file, {
24
+ output: progressPath(this.opts.dir),
25
+ jobDir: this.opts.dir,
26
+ editor: this.opts.editor,
27
+ signal: this.abortController.signal,
28
+ onPropose: async (proposal) => {
29
+ if (this.stopped || this.abortController.signal.aborted)
30
+ return;
31
+ const completed = await completeReview(this.opts.dir, proposal, this.opts.claim.token);
32
+ canonical = completed.result;
33
+ await this.opts.onSubmitted?.(canonical);
34
+ },
35
+ }).then(() => canonical).finally(() => {
36
+ clearInterval(this.heartbeat);
37
+ releaseClaim(this.opts.dir, this.opts.claim.token);
38
+ this.stopped = true;
39
+ });
40
+ return this.running;
41
+ }
42
+ /** Toggle, cancellation, and controller teardown all take this same path. */
43
+ async stop() {
44
+ if (this.stopped)
45
+ return;
46
+ this.stopped = true;
47
+ this.abortController.abort();
48
+ try {
49
+ await this.running;
50
+ }
51
+ finally {
52
+ clearInterval(this.heartbeat);
53
+ releaseClaim(this.opts.dir, this.opts.claim.token);
54
+ }
55
+ }
56
+ }
@@ -1,2 +1,3 @@
1
- import type { InboxItem } from '../types.js';
2
- export declare function scanInbox(roots: string[]): InboxItem[];
1
+ import type { TicketSummary } from '../types.js';
2
+ /** Read all unresolved deck/review tickets, newest first. Progress never affects visibility. */
3
+ export declare function scanInbox(roots?: string[]): TicketSummary[];