@crouton-kit/humanloop 0.3.38 → 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.
Files changed (46) hide show
  1. package/dist/api.d.ts +3 -2
  2. package/dist/api.js +2 -2
  3. package/dist/browser/server.d.ts +15 -5
  4. package/dist/browser/server.js +110 -14
  5. package/dist/cli.js +0 -0
  6. package/dist/editor/roundtrip.d.ts +5 -0
  7. package/dist/editor/roundtrip.js +36 -0
  8. package/dist/inbox/completion.js +1 -27
  9. package/dist/inbox/controller.d.ts +32 -3
  10. package/dist/inbox/controller.js +427 -35
  11. package/dist/inbox/convention.d.ts +9 -2
  12. package/dist/inbox/convention.js +51 -3
  13. package/dist/inbox/deck-adapter.d.ts +10 -2
  14. package/dist/inbox/deck-adapter.js +23 -12
  15. package/dist/inbox/deck-schema.d.ts +2 -0
  16. package/dist/inbox/deck-schema.js +4 -4
  17. package/dist/inbox/followup.d.ts +57 -0
  18. package/dist/inbox/followup.js +117 -0
  19. package/dist/inbox/registry.d.ts +4 -0
  20. package/dist/inbox/registry.js +9 -4
  21. package/dist/inbox/tickets.d.ts +5 -0
  22. package/dist/inbox/tickets.js +25 -32
  23. package/dist/inbox/tui.d.ts +1 -1
  24. package/dist/inbox/tui.js +58 -25
  25. package/dist/inbox/visual.d.ts +130 -0
  26. package/dist/inbox/visual.js +747 -0
  27. package/dist/index.d.ts +5 -3
  28. package/dist/index.js +2 -1
  29. package/dist/tui/app.d.ts +3 -6
  30. package/dist/tui/app.js +130 -97
  31. package/dist/tui/input.d.ts +5 -1
  32. package/dist/tui/input.js +41 -15
  33. package/dist/tui/log.d.ts +2 -0
  34. package/dist/tui/log.js +53 -0
  35. package/dist/tui/render.js +44 -27
  36. package/dist/tui/terminal.d.ts +1 -0
  37. package/dist/tui/terminal.js +5 -0
  38. package/dist/tui/tmux.js +44 -22
  39. package/dist/types.d.ts +71 -7
  40. package/dist/web/assets/{index-DhbBiRqS.js → index-YFwgZZMg.js} +2 -2
  41. package/dist/web/index.html +1 -1
  42. package/package.json +6 -3
  43. package/dist/conversation/reader.d.ts +0 -6
  44. package/dist/conversation/reader.js +0 -58
  45. package/dist/visuals/generate.d.ts +0 -9
  46. package/dist/visuals/generate.js +0 -79
@@ -6,15 +6,22 @@ export declare function progressPath(dir: string): string;
6
6
  export declare function claimPath(dir: string): string;
7
7
  export declare function deliveryPath(dir: string): string;
8
8
  export declare function deliveryErrorPath(dir: string): string;
9
+ export declare function followupRequestPath(dir: string): string;
10
+ export declare function followupResultPath(dir: string): string;
9
11
  export declare function visualsDir(dir: string): string;
10
- export declare function visualMdPath(dir: string, id: string): string;
11
- export declare function visualAnsiPath(dir: string, id: string): string;
12
+ /** Spawns a handler `{command,args}` with `event` as JSON on stdin. Exit 0
13
+ * acknowledges; a nonzero exit, spawn error, or 30s timeout rejects with an
14
+ * Error carrying the handler's captured stderr. Shared by completion delivery
15
+ * and follow-up dispatch — both invoke a registered handler the same way. */
16
+ export declare function runHandler(command: string, args: string[], event: unknown): Promise<void>;
12
17
  export type InteractionState = 'pending' | 'claimed' | 'resolved' | 'missing';
13
18
  export declare function interactionState(dir: string): InteractionState;
14
19
  export declare function isResolved(dir: string): boolean;
15
20
  export declare function isClaimed(dir: string): boolean;
16
21
  export declare function stampCanvasNode(deck: Deck): void;
17
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;
18
25
  export declare function readJson<T>(path: string): T | null;
19
26
  /** Runs a short filesystem transition under a token-checked, crash-reclaimable directory lock. */
20
27
  export declare function withExclusiveDirectoryLock<T>(path: string, operation: () => T, options?: {
@@ -1,4 +1,5 @@
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
+ import { spawn } from 'node:child_process';
2
3
  import { dirname } from 'node:path';
3
4
  import { randomUUID } from 'node:crypto';
4
5
  import { buildSummary } from '../summary.js';
@@ -9,9 +10,38 @@ export function progressPath(dir) { return `${dir}/progress.json`; }
9
10
  export function claimPath(dir) { return `${dir}/claim.json`; }
10
11
  export function deliveryPath(dir) { return `${dir}/delivery.json`; }
11
12
  export function deliveryErrorPath(dir) { return `${dir}/delivery-error.json`; }
13
+ export function followupRequestPath(dir) { return `${dir}/followup-request.json`; }
14
+ export function followupResultPath(dir) { return `${dir}/followup-result.json`; }
12
15
  export function visualsDir(dir) { return `${dir}/visuals`; }
13
- export function visualMdPath(dir, id) { return `${dir}/visuals/${id}.md`; }
14
- export function visualAnsiPath(dir, id) { return `${dir}/visuals/${id}.ansi`; }
16
+ /** Spawns a handler `{command,args}` with `event` as JSON on stdin. Exit 0
17
+ * acknowledges; a nonzero exit, spawn error, or 30s timeout rejects with an
18
+ * Error carrying the handler's captured stderr. Shared by completion delivery
19
+ * and follow-up dispatch — both invoke a registered handler the same way. */
20
+ export async function runHandler(command, args, event) {
21
+ await new Promise((resolvePromise, rejectPromise) => {
22
+ // stdout stays ignored (a handler acknowledges by exit code, never stdout),
23
+ // but stderr is captured so a nonzero exit surfaces the handler's own
24
+ // diagnostics in the delivery-error record instead of a bare exit code.
25
+ const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'pipe'] });
26
+ let stderr = '';
27
+ child.stderr?.on('data', (chunk) => { stderr += chunk; if (stderr.length > 8192)
28
+ stderr = stderr.slice(-8192); });
29
+ let timedOut = false;
30
+ const timeout = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 30_000);
31
+ child.once('error', (error) => { clearTimeout(timeout); rejectPromise(error); });
32
+ child.once('exit', (code, signal) => {
33
+ clearTimeout(timeout);
34
+ const detail = stderr.trim() === '' ? '' : `: ${stderr.trim()}`;
35
+ if (timedOut)
36
+ rejectPromise(new Error(`handler timed out after 30 seconds${detail}`));
37
+ else if (code === 0)
38
+ resolvePromise();
39
+ else
40
+ rejectPromise(new Error(`handler failed (${signal ?? code ?? 'unknown'})${detail}`));
41
+ });
42
+ child.stdin.end(`${JSON.stringify(event)}\n`);
43
+ });
44
+ }
15
45
  export function interactionState(dir) {
16
46
  if (!existsSync(deckPath(dir)) && !existsSync(reviewPath(dir)))
17
47
  return 'missing';
@@ -33,6 +63,24 @@ export function atomicWriteJson(path, value) {
33
63
  writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
34
64
  renameSync(tmp, path);
35
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
+ }
36
84
  export function readJson(path) {
37
85
  try {
38
86
  return JSON.parse(readFileSync(path, 'utf8'));
@@ -1,4 +1,4 @@
1
- import type { Deck, 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,6 +9,11 @@ export interface DeckAdapterOptions {
9
9
  onComplete: (responses: InteractionResponse[]) => void;
10
10
  onBack: () => void;
11
11
  onDirty: () => void;
12
+ visualProvider?: VisualProvider;
13
+ onEditorRequest?: () => void;
14
+ followUpAvailable?: boolean;
15
+ onFollowUpRequest?: (question: string) => void;
16
+ onFollowUpCancel?: () => void;
12
17
  }
13
18
  /** Embeds the single deck renderer in a controller-owned rectangle. */
14
19
  export declare class DeckAdapter {
@@ -19,9 +24,12 @@ export declare class DeckAdapter {
19
24
  render(): string[];
20
25
  resize(cols: number, rows: number): string[];
21
26
  inputBuffer(): string | undefined;
27
+ setInputBuffer(text: string): void;
28
+ setFollowUpHandlers(available: boolean, onRequest?: (question: string) => void, onCancel?: () => void): void;
29
+ setFollowUpState(state: FollowUpState): void;
22
30
  canAcceptHostKeys(): boolean;
23
31
  handleKey(input: string, key: Key): void;
24
32
  /** Fresh descriptor reads preserve mounted answers for interaction ids still present. */
25
- reload(): void;
33
+ reload(deck: Deck, visualProvider: VisualProvider | undefined): void;
26
34
  close(): void;
27
35
  }
@@ -1,6 +1,4 @@
1
- import { readFileSync } from 'node:fs';
2
- import { deckPath, progressPath, readJson } from './convention.js';
3
- import { validateDeck } from './deck-schema.js';
1
+ import { progressPath, readJson } from './convention.js';
4
2
  import { mountPanel } from '../tui/app.js';
5
3
  /** Embeds the single deck renderer in a controller-owned rectangle. */
6
4
  export class DeckAdapter {
@@ -17,13 +15,24 @@ export class DeckAdapter {
17
15
  rows: opts.rows,
18
16
  onProgress: (responses) => { this.responses = responses; opts.onProgress?.(responses); },
19
17
  onComplete: opts.onComplete,
20
- onExit: () => opts.onComplete(this.responses),
18
+ // A partial-deck exit is a valid completion for ordinary asks, but a
19
+ // notification is resolved only by its explicit acknowledgement.
20
+ onExit: () => { if (notificationsAcknowledged(opts.deck, this.responses))
21
+ opts.onComplete(this.responses); },
21
22
  onDirty: opts.onDirty,
23
+ visualProvider: opts.visualProvider,
24
+ onEditorRequest: opts.onEditorRequest,
25
+ followUpAvailable: opts.followUpAvailable,
26
+ onFollowUpRequest: opts.onFollowUpRequest,
27
+ onFollowUpCancel: opts.onFollowUpCancel,
22
28
  });
23
29
  }
24
30
  render() { return this.panel.render(); }
25
31
  resize(cols, rows) { return this.panel.handleResize(cols, rows); }
26
32
  inputBuffer() { return this.panel.getInputBuffer(); }
33
+ setInputBuffer(text) { this.panel.setInputBuffer(text); }
34
+ setFollowUpHandlers(available, onRequest, onCancel) { this.panel.setFollowUpHandlers(available, onRequest, onCancel); }
35
+ setFollowUpState(state) { this.panel.setFollowUpState(state); }
27
36
  canAcceptHostKeys() { return this.panel.canAcceptHostKeys(); }
28
37
  handleKey(input, key) {
29
38
  if (key.escape && this.panel.atDeckTop()) {
@@ -33,17 +42,19 @@ export class DeckAdapter {
33
42
  this.panel.handleKey(input, key);
34
43
  }
35
44
  /** 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
- }
45
+ reload(deck, visualProvider) {
46
+ this.panel.loadDeck(deck, { progressPath: progressPath(this.opts.dir), visualProvider });
47
+ this.opts.onDirty();
44
48
  }
45
49
  close() { this.panel.unmount(); }
46
50
  }
51
+ function notificationsAcknowledged(deck, responses) {
52
+ const byId = new Map(responses.map((response) => [response.id, response]));
53
+ return deck.interactions.filter((interaction) => interaction.kind === 'notify').every((interaction) => {
54
+ const response = byId.get(interaction.id);
55
+ return response !== undefined && (response.selectedOptionId !== undefined || (response.selectedOptionIds?.length ?? 0) > 0 || (response.freetext?.trim() ?? '') !== '');
56
+ });
57
+ }
47
58
  function initialResponses(deck, dir) {
48
59
  const saved = readJson(progressPath(dir))?.responses;
49
60
  if (Array.isArray(saved))
@@ -18,6 +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
+ visual: z.ZodOptional<z.ZodLiteral<"humanloop.visual/v1">>;
21
22
  }, z.core.$strip>>;
22
23
  interactions: z.ZodArray<z.ZodObject<{
23
24
  id: z.ZodString;
@@ -58,6 +59,7 @@ export declare const reviewDescriptorSchema: z.ZodObject<{
58
59
  askedBy: z.ZodOptional<z.ZodString>;
59
60
  blockedSince: z.ZodOptional<z.ZodString>;
60
61
  nodeId: z.ZodOptional<z.ZodString>;
62
+ visual: z.ZodOptional<z.ZodLiteral<"humanloop.visual/v1">>;
61
63
  }, z.core.$strip>;
62
64
  blockedSince: z.ZodString;
63
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, 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() });
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']);
81
- const ownProtocolPaths = new Set([deckPath(dir), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir), deliveryPath(dir), deliveryErrorPath(dir)]);
80
+ const reserved = new Set(['deck.json', 'review.json', 'response.json', 'progress.json', 'claim.json', 'delivery.json', 'delivery-error.json', 'followup-request.json', 'followup-result.json', 'visuals']);
81
+ const ownProtocolPaths = new Set([deckPath(dir), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir), deliveryPath(dir), deliveryErrorPath(dir), followupRequestPath(dir), followupResultPath(dir), visualsDir(dir)]);
82
82
  if (output === file || reserved.has(basename(output)) || ownProtocolPaths.has(output))
83
83
  throw new Error('review output must not alias the source or ticket protocol files');
84
84
  return { ...descriptor, file, output };
@@ -0,0 +1,57 @@
1
+ export interface FollowUpRequestEvent {
2
+ schema: 'humanloop.followup-request/v1';
3
+ action: 'start' | 'cancel';
4
+ capability: 'humanloop.follow-up/v1';
5
+ owner: string;
6
+ root: string;
7
+ dir: string;
8
+ ticketId: string;
9
+ requestId: string;
10
+ question: string;
11
+ }
12
+ /** followup-request.json — humanloop-owned. A state machine, not delete-on-cancel;
13
+ * the sole writer is humanloop, always under `.followup-lock`. */
14
+ export interface FollowUpRequest {
15
+ schema: 'humanloop.followup-request/v1';
16
+ requestId: string;
17
+ question: string;
18
+ state: 'running' | 'canceled' | 'superseded' | 'terminal';
19
+ askedAt: string;
20
+ settledAt?: string;
21
+ }
22
+ /** followup-result.json — the provider's writer, via `submitFollowUpResult`'s
23
+ * compare-and-publish gate. */
24
+ export interface FollowUpResult {
25
+ schema: 'humanloop.followup-result/v1';
26
+ requestId: string;
27
+ status: 'ready' | 'error';
28
+ markdown?: string;
29
+ error?: string;
30
+ completedAt: string;
31
+ }
32
+ /** Supersede any running request, write a fresh `running` request, and fire a
33
+ * best-effort `start` kickoff to the registered handler. */
34
+ export declare function requestFollowUp(root: string, dir: string, opts: {
35
+ question: string;
36
+ }): FollowUpRequest;
37
+ /** Mark the current running request `canceled` (never deleted) and best-effort
38
+ * notify the handler. A no-op when nothing is running. */
39
+ export declare function cancelFollowUp(root: string, dir: string): void;
40
+ /** The provider's writer. Canonicalizes a registered direct-child ticket, then
41
+ * publishes ONLY if `result.requestId` matches the current `running` request
42
+ * under `.followup-lock` \u2014 the authoritative guard that a superseded/canceled
43
+ * writer's late answer can never clobber a newer result. A stale writer is a
44
+ * silent no-op. A `ready` whose markdown fails `checkMarkdown` is downgraded
45
+ * to an `error` result, never thrown. */
46
+ export declare function submitFollowUpResult(root: string, dir: string, result: {
47
+ requestId: string;
48
+ status: 'ready' | 'error';
49
+ markdown?: string;
50
+ error?: string;
51
+ }): {
52
+ published: boolean;
53
+ };
54
+ export declare function readFollowUp(dir: string): {
55
+ request: FollowUpRequest | null;
56
+ result: FollowUpResult | null;
57
+ };
@@ -0,0 +1,117 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { basename } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { atomicWriteJson, followupRequestPath, followupResultPath, readJson, runHandler, withExclusiveDirectoryLock } from './convention.js';
5
+ import { registeredInboxRoot } from './registry.js';
6
+ import { ticketRoot } from './tickets.js';
7
+ import { checkMarkdown } from '../render/termrender.js';
8
+ const CAPABILITY = 'humanloop.follow-up/v1';
9
+ function lockPath(dir) { return `${dir}/.followup-lock`; }
10
+ function requireCanonicalTicket(root, dir) {
11
+ const registration = registeredInboxRoot(root);
12
+ let canonicalDir;
13
+ try {
14
+ canonicalDir = realpathSync(dir);
15
+ }
16
+ catch {
17
+ throw new Error('ticket is not a canonical direct child of a registered root');
18
+ }
19
+ if (registration === null || ticketRoot(canonicalDir) !== registration.root)
20
+ throw new Error('ticket is not a canonical direct child of a registered root');
21
+ return { root: registration.root, dir: canonicalDir, owner: registration.owner, followUpHandler: registration.followUpHandler };
22
+ }
23
+ function requireFollowUpHandler(root, dir) {
24
+ const ticket = requireCanonicalTicket(root, dir);
25
+ if (ticket.followUpHandler === undefined)
26
+ throw new Error('root has no registered follow-up handler');
27
+ return { ...ticket, followUpHandler: ticket.followUpHandler };
28
+ }
29
+ function normalizeResult(result) {
30
+ const completedAt = new Date().toISOString();
31
+ if (result.status === 'ready') {
32
+ if (typeof result.markdown !== 'string' || result.markdown.trim().length === 0) {
33
+ return { schema: 'humanloop.followup-result/v1', requestId: result.requestId, status: 'error', error: 'ready result requires non-empty markdown', completedAt };
34
+ }
35
+ const check = checkMarkdown(result.markdown);
36
+ if (!check.ok)
37
+ return { schema: 'humanloop.followup-result/v1', requestId: result.requestId, status: 'error', error: check.error, completedAt };
38
+ return { schema: 'humanloop.followup-result/v1', requestId: result.requestId, status: 'ready', markdown: result.markdown, completedAt };
39
+ }
40
+ return { schema: 'humanloop.followup-result/v1', requestId: result.requestId, status: 'error', error: result.error ?? 'unknown error', completedAt };
41
+ }
42
+ /** Supersede any running request, write a fresh `running` request, and fire a
43
+ * best-effort `start` kickoff to the registered handler. */
44
+ export function requestFollowUp(root, dir, opts) {
45
+ const { root: canonicalRoot, dir: canonicalDir, owner, followUpHandler } = requireFollowUpHandler(root, dir);
46
+ const request = {
47
+ schema: 'humanloop.followup-request/v1',
48
+ requestId: randomUUID(),
49
+ question: opts.question,
50
+ state: 'running',
51
+ askedAt: new Date().toISOString(),
52
+ };
53
+ withExclusiveDirectoryLock(lockPath(canonicalDir), () => {
54
+ const current = readJson(followupRequestPath(canonicalDir));
55
+ if (current !== null && current.state === 'running') {
56
+ atomicWriteJson(followupRequestPath(canonicalDir), { ...current, state: 'superseded', settledAt: new Date().toISOString() });
57
+ }
58
+ atomicWriteJson(followupRequestPath(canonicalDir), request);
59
+ });
60
+ const event = {
61
+ schema: 'humanloop.followup-request/v1', action: 'start', capability: CAPABILITY,
62
+ owner, root: canonicalRoot, dir: canonicalDir, ticketId: basename(canonicalDir),
63
+ requestId: request.requestId, question: opts.question,
64
+ };
65
+ void runHandler(followUpHandler.command, followUpHandler.args, event).catch((error) => {
66
+ submitFollowUpResult(canonicalRoot, canonicalDir, { requestId: request.requestId, status: 'error', error: error instanceof Error ? error.message : String(error) });
67
+ });
68
+ return request;
69
+ }
70
+ /** Mark the current running request `canceled` (never deleted) and best-effort
71
+ * notify the handler. A no-op when nothing is running. */
72
+ export function cancelFollowUp(root, dir) {
73
+ const { owner, root: canonicalRoot, dir: canonicalDir, followUpHandler } = requireFollowUpHandler(root, dir);
74
+ const canceled = withExclusiveDirectoryLock(lockPath(canonicalDir), () => {
75
+ const current = readJson(followupRequestPath(canonicalDir));
76
+ if (current === null || current.state !== 'running')
77
+ return null;
78
+ atomicWriteJson(followupRequestPath(canonicalDir), { ...current, state: 'canceled', settledAt: new Date().toISOString() });
79
+ return current;
80
+ });
81
+ if (canceled === null)
82
+ return;
83
+ const event = {
84
+ schema: 'humanloop.followup-request/v1', action: 'cancel', capability: CAPABILITY,
85
+ owner, root: canonicalRoot, dir: canonicalDir, ticketId: basename(canonicalDir),
86
+ requestId: canceled.requestId, question: '',
87
+ };
88
+ void runHandler(followUpHandler.command, followUpHandler.args, event).catch(() => { });
89
+ }
90
+ /** The provider's writer. Canonicalizes a registered direct-child ticket, then
91
+ * publishes ONLY if `result.requestId` matches the current `running` request
92
+ * under `.followup-lock` \u2014 the authoritative guard that a superseded/canceled
93
+ * writer's late answer can never clobber a newer result. A stale writer is a
94
+ * silent no-op. A `ready` whose markdown fails `checkMarkdown` is downgraded
95
+ * to an `error` result, never thrown. */
96
+ export function submitFollowUpResult(root, dir, result) {
97
+ const { dir: canonicalDir } = requireCanonicalTicket(root, dir);
98
+ const normalized = normalizeResult(result);
99
+ return withExclusiveDirectoryLock(lockPath(canonicalDir), () => {
100
+ const current = readJson(followupRequestPath(canonicalDir));
101
+ if (current === null || current.requestId !== result.requestId || current.state !== 'running')
102
+ return { published: false };
103
+ atomicWriteJson(followupResultPath(canonicalDir), normalized);
104
+ atomicWriteJson(followupRequestPath(canonicalDir), { ...current, state: 'terminal', settledAt: normalized.completedAt });
105
+ return { published: true };
106
+ });
107
+ }
108
+ export function readFollowUp(dir) {
109
+ let canonicalDir;
110
+ try {
111
+ canonicalDir = realpathSync(dir);
112
+ }
113
+ catch {
114
+ return { request: null, result: null };
115
+ }
116
+ return { request: readJson(followupRequestPath(canonicalDir)), result: readJson(followupResultPath(canonicalDir)) };
117
+ }
@@ -8,6 +8,8 @@ export interface InboxRootRegistration {
8
8
  root: string;
9
9
  owner: string;
10
10
  handler?: CompletionHandler;
11
+ followUpHandler?: CompletionHandler;
12
+ visualHandler?: CompletionHandler;
11
13
  }
12
14
  export interface InboxRootStatus extends InboxRootRegistration {
13
15
  available: boolean;
@@ -16,6 +18,8 @@ export interface RegisterInboxRootOptions {
16
18
  root: string;
17
19
  owner: string;
18
20
  handler?: CompletionHandler;
21
+ followUpHandler?: CompletionHandler;
22
+ visualHandler?: CompletionHandler;
19
23
  }
20
24
  export declare function inboxRootsDirectory(): string;
21
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) {
@@ -26,7 +26,12 @@ function validateRegistration(raw) {
26
26
  if (value.schema !== 'humanloop.inbox-root/v1' || typeof value.root !== 'string' || !value.root || typeof value.owner !== 'string' || !value.owner.trim())
27
27
  return null;
28
28
  try {
29
- return { schema: 'humanloop.inbox-root/v1', root: value.root, owner: value.owner, handler: value.handler === undefined ? undefined : validateHandler(value.handler) };
29
+ return {
30
+ schema: 'humanloop.inbox-root/v1', root: value.root, owner: value.owner,
31
+ handler: value.handler === undefined ? undefined : validateHandler(value.handler),
32
+ followUpHandler: value.followUpHandler === undefined ? undefined : validateHandler(value.followUpHandler),
33
+ visualHandler: value.visualHandler === undefined ? undefined : validateHandler(value.visualHandler),
34
+ };
30
35
  }
31
36
  catch {
32
37
  return null;
@@ -46,7 +51,7 @@ export function registerInboxRoot(opts) {
46
51
  throw new Error(`inbox root is already owned by ${existing.owner}`);
47
52
  if (existing !== null && existing.root !== root)
48
53
  throw new Error('inbox root registry hash collision');
49
- const registration = { schema: 'humanloop.inbox-root/v1', root, owner: opts.owner, handler: validateHandler(opts.handler) };
54
+ const registration = { schema: 'humanloop.inbox-root/v1', root, owner: opts.owner, handler: validateHandler(opts.handler), followUpHandler: validateHandler(opts.followUpHandler), visualHandler: validateHandler(opts.visualHandler) };
50
55
  atomicWriteJson(path, registration);
51
56
  chmodSync(path, 0o600);
52
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, 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)].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'))); }
@@ -172,6 +155,12 @@ function validateDeckResponses(deck, raw) {
172
155
  if (response.optionComments !== undefined && (interaction.multiSelect !== true || Object.keys(response.optionComments).some((id) => !optionIds.has(id))))
173
156
  throw new Error(`invalid option comments for ${response.id}`);
174
157
  }
158
+ for (const interaction of deck.interactions.filter((candidate) => candidate.kind === 'notify')) {
159
+ const response = responses.find((candidate) => candidate.id === interaction.id);
160
+ if (response === undefined || (response.selectedOptionId === undefined && (response.selectedOptionIds?.length ?? 0) === 0 && (response.freetext?.trim() ?? '') === '')) {
161
+ throw new Error(`notification requires an explicit acknowledgement: ${interaction.id}`);
162
+ }
163
+ }
175
164
  return responses;
176
165
  }
177
166
  function requireClaimOwnership(dir, token) {
@@ -185,6 +174,10 @@ export function finalizeDeck(dir, responses, claimToken, completedAt = new Date(
185
174
  requireClaimOwnership(ticket.dir, claimToken);
186
175
  const deck = requireDeck(ticket.dir);
187
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);
188
181
  const result = { schema: 'humanloop.response/v2', kind: 'deck', responses: parsedResponses, summary: buildSummary(deck, parsedResponses), completedAt };
189
182
  const won = exclusiveResult(ticket.dir, result);
190
183
  clearOwnedWork(ticket.dir, claimToken);
@@ -3,4 +3,4 @@ export declare const KIND_ICON: Record<string, string>;
3
3
  export declare const KIND_COLOR: Record<string, string>;
4
4
  export declare function formatTimeAgo(iso: string): string;
5
5
  /** Pure inbox rows; selection and terminal ownership belong to InboxController. */
6
- export declare function buildInboxLines(items: TicketSummary[], width: number, selectedIndex: number): string[];
6
+ export declare function buildInboxLines(items: TicketSummary[], width: number, selectedIndex: number, height?: number): string[];