@crouton-kit/humanloop 0.3.37 → 0.3.39

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/browser/server.d.ts +15 -5
  2. package/dist/browser/server.js +110 -14
  3. package/dist/cli.js +0 -0
  4. package/dist/conversation/reader.d.ts +15 -1
  5. package/dist/conversation/reader.js +320 -30
  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 +36 -1
  10. package/dist/inbox/controller.js +335 -28
  11. package/dist/inbox/convention.d.ts +7 -0
  12. package/dist/inbox/convention.js +32 -0
  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 +2 -0
  20. package/dist/inbox/registry.js +6 -2
  21. package/dist/inbox/tickets.js +8 -2
  22. package/dist/inbox/tui.d.ts +1 -1
  23. package/dist/inbox/tui.js +58 -25
  24. package/dist/index.d.ts +3 -1
  25. package/dist/index.js +1 -0
  26. package/dist/render/termrender.js +25 -26
  27. package/dist/tui/app.d.ts +1 -1
  28. package/dist/tui/app.js +80 -88
  29. package/dist/tui/input.d.ts +5 -1
  30. package/dist/tui/input.js +41 -15
  31. package/dist/tui/log.d.ts +2 -0
  32. package/dist/tui/log.js +53 -0
  33. package/dist/tui/render.js +42 -23
  34. package/dist/tui/terminal.d.ts +1 -0
  35. package/dist/tui/terminal.js +5 -0
  36. package/dist/tui/tmux.js +44 -22
  37. package/dist/types.d.ts +39 -2
  38. package/dist/visuals/conversation.d.ts +7 -0
  39. package/dist/visuals/conversation.js +16 -0
  40. package/dist/visuals/generate.d.ts +3 -1
  41. package/dist/visuals/generate.js +8 -6
  42. package/dist/web/assets/{index-DhbBiRqS.js → index-YFwgZZMg.js} +2 -2
  43. package/dist/web/index.html +1 -1
  44. package/package.json +7 -2
@@ -1,4 +1,5 @@
1
1
  import { existsSync, 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,40 @@ 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
16
  export function visualMdPath(dir, id) { return `${dir}/visuals/${id}.md`; }
14
17
  export function visualAnsiPath(dir, id) { return `${dir}/visuals/${id}.ansi`; }
18
+ /** Spawns a handler `{command,args}` with `event` as JSON on stdin. Exit 0
19
+ * acknowledges; a nonzero exit, spawn error, or 30s timeout rejects with an
20
+ * Error carrying the handler's captured stderr. Shared by completion delivery
21
+ * and follow-up dispatch — both invoke a registered handler the same way. */
22
+ export async function runHandler(command, args, event) {
23
+ await new Promise((resolvePromise, rejectPromise) => {
24
+ // stdout stays ignored (a handler acknowledges by exit code, never stdout),
25
+ // but stderr is captured so a nonzero exit surfaces the handler's own
26
+ // diagnostics in the delivery-error record instead of a bare exit code.
27
+ const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'pipe'] });
28
+ let stderr = '';
29
+ child.stderr?.on('data', (chunk) => { stderr += chunk; if (stderr.length > 8192)
30
+ stderr = stderr.slice(-8192); });
31
+ let timedOut = false;
32
+ const timeout = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 30_000);
33
+ child.once('error', (error) => { clearTimeout(timeout); rejectPromise(error); });
34
+ child.once('exit', (code, signal) => {
35
+ clearTimeout(timeout);
36
+ const detail = stderr.trim() === '' ? '' : `: ${stderr.trim()}`;
37
+ if (timedOut)
38
+ rejectPromise(new Error(`handler timed out after 30 seconds${detail}`));
39
+ else if (code === 0)
40
+ resolvePromise();
41
+ else
42
+ rejectPromise(new Error(`handler failed (${signal ?? code ?? 'unknown'})${detail}`));
43
+ });
44
+ child.stdin.end(`${JSON.stringify(event)}\n`);
45
+ });
46
+ }
15
47
  export function interactionState(dir) {
16
48
  if (!existsSync(deckPath(dir)) && !existsSync(reviewPath(dir)))
17
49
  return 'missing';
@@ -1,4 +1,4 @@
1
- import type { Deck, InteractionResponse } from '../types.js';
1
+ import type { Deck, FollowUpState, GenerateVisual, InteractionResponse } 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
+ generateVisual?: GenerateVisual;
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): 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
+ generateVisual: opts.generateVisual,
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) {
46
+ this.panel.loadDeck(deck, { progressPath: progressPath(this.opts.dir) });
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
+ originatingConversationSessionId: z.ZodOptional<z.ZodString>;
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
+ originatingConversationSessionId: z.ZodOptional<z.ZodString>;
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 } 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(), originatingConversationSessionId: z.string().min(1).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']);
81
+ const ownProtocolPaths = new Set([deckPath(dir), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir), deliveryPath(dir), deliveryErrorPath(dir), followupRequestPath(dir), followupResultPath(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,7 @@ export interface InboxRootRegistration {
8
8
  root: string;
9
9
  owner: string;
10
10
  handler?: CompletionHandler;
11
+ followUpHandler?: CompletionHandler;
11
12
  }
12
13
  export interface InboxRootStatus extends InboxRootRegistration {
13
14
  available: boolean;
@@ -16,6 +17,7 @@ export interface RegisterInboxRootOptions {
16
17
  root: string;
17
18
  owner: string;
18
19
  handler?: CompletionHandler;
20
+ followUpHandler?: CompletionHandler;
19
21
  }
20
22
  export declare function inboxRootsDirectory(): string;
21
23
  /** Create/canonicalize a root and claim its user-scoped registration. */
@@ -26,7 +26,11 @@ 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
+ };
30
34
  }
31
35
  catch {
32
36
  return null;
@@ -46,7 +50,7 @@ export function registerInboxRoot(opts) {
46
50
  throw new Error(`inbox root is already owned by ${existing.owner}`);
47
51
  if (existing !== null && existing.root !== root)
48
52
  throw new Error('inbox root registry hash collision');
49
- const registration = { schema: 'humanloop.inbox-root/v1', root, owner: opts.owner, handler: validateHandler(opts.handler) };
53
+ const registration = { schema: 'humanloop.inbox-root/v1', root, owner: opts.owner, handler: validateHandler(opts.handler), followUpHandler: validateHandler(opts.followUpHandler) };
50
54
  atomicWriteJson(path, registration);
51
55
  chmodSync(path, 0o600);
52
56
  return registration;
@@ -2,7 +2,7 @@ import { existsSync, linkSync, mkdirSync, readFileSync, realpathSync, rmSync, un
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, responsePath, reviewPath } 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';
@@ -70,7 +70,7 @@ function ticketDir(root, id) {
70
70
  function discardCreatedTicket(dir, created) { if (created)
71
71
  rmSync(dir, { recursive: true, force: true }); }
72
72
  function hasTicketProtocolState(dir) {
73
- return [deckPath(dir), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir), deliveryPath(dir), deliveryErrorPath(dir)].some(existsSync);
73
+ return [deckPath(dir), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir), deliveryPath(dir), deliveryErrorPath(dir), followupRequestPath(dir), followupResultPath(dir)].some(existsSync);
74
74
  }
75
75
  function requireRegisteredTicket(dir) {
76
76
  const canonical = realpathSync(dir);
@@ -172,6 +172,12 @@ function validateDeckResponses(deck, raw) {
172
172
  if (response.optionComments !== undefined && (interaction.multiSelect !== true || Object.keys(response.optionComments).some((id) => !optionIds.has(id))))
173
173
  throw new Error(`invalid option comments for ${response.id}`);
174
174
  }
175
+ for (const interaction of deck.interactions.filter((candidate) => candidate.kind === 'notify')) {
176
+ const response = responses.find((candidate) => candidate.id === interaction.id);
177
+ if (response === undefined || (response.selectedOptionId === undefined && (response.selectedOptionIds?.length ?? 0) === 0 && (response.freetext?.trim() ?? '') === '')) {
178
+ throw new Error(`notification requires an explicit acknowledgement: ${interaction.id}`);
179
+ }
180
+ }
175
181
  return responses;
176
182
  }
177
183
  function requireClaimOwnership(dir, token) {
@@ -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[];
package/dist/inbox/tui.js CHANGED
@@ -19,33 +19,66 @@ export function formatTimeAgo(iso) {
19
19
  return 'just now';
20
20
  }
21
21
  /** Pure inbox rows; selection and terminal ownership belong to InboxController. */
22
- export function buildInboxLines(items, width, selectedIndex) {
22
+ export function buildInboxLines(items, width, selectedIndex, height = Number.MAX_SAFE_INTEGER) {
23
23
  if (items.length === 0)
24
24
  return [` ${DIM}${ITALIC}No pending interactions${RESET}`];
25
- const lines = [` ${BOLD}${items.length} pending${RESET}`, ''];
26
25
  const contentWidth = width - 4;
27
- for (let index = 0; index < items.length; index++) {
28
- const item = items[index];
29
- const kind = item.kind === 'deck' ? item.interactionKind ?? 'decision' : 'review';
30
- const icon = KIND_ICON[kind] ?? '·';
31
- // Source and title share the row budget: an unbounded source (a long node
32
- // or session name) would floor the title at its minimum and overflow the
33
- // column, bending the panel divider. Shrink the source only when the row
34
- // cannot hold it alongside the title's 10-col minimum.
35
- const rawSource = item.source.sessionName ?? item.source.askedBy ?? item.source.nodeId ?? '';
36
- const age = formatTimeAgo(item.blockedSince);
37
- const source = truncateRow(rawSource, Math.max(8, contentWidth - age.length - 8 - 10));
38
- const cursor = index === selectedIndex ? `${CYAN}▸${RESET} ` : ' ';
39
- const titleWidth = Math.max(10, contentWidth - source.length - age.length - 8);
40
- let row = `${cursor}${ansiColor(icon, KIND_COLOR[kind] ?? 'cyan')} `;
41
- if (source)
42
- row += `${ansiColor(source, 'yellow')} ${DIM}·${RESET} `;
43
- row += `${BOLD}${truncateRow(item.title || `(${item.id.slice(0, 8)})`, titleWidth)}${RESET} ${DIM}${age}${RESET}`;
44
- lines.push(row);
45
- if (item.claim)
46
- lines.push(` ${DIM}${truncateRow(`claimed by ${item.claim.owner}`, contentWidth - 6)}${RESET}`);
47
- else if (item.subtitle)
48
- lines.push(` ${DIM}${truncateRow(item.subtitle, contentWidth - 6)}${RESET}`);
49
- }
26
+ const itemLines = items.map((item, index) => buildItemLines(item, index, selectedIndex, contentWidth));
27
+ const window = visibleWindow(itemLines, selectedIndex, Math.max(1, height - 2));
28
+ const lines = [` ${BOLD}${items.length} pending${RESET}`, ''];
29
+ if (window.start > 0)
30
+ lines.push(` ${DIM}↑ ${window.start} above${RESET}`);
31
+ for (let index = window.start; index < window.end; index++)
32
+ lines.push(...itemLines[index]);
33
+ if (window.end < items.length)
34
+ lines.push(` ${DIM}↓ ${items.length - window.end} below${RESET}`);
50
35
  return lines;
51
36
  }
37
+ function buildItemLines(item, index, selectedIndex, contentWidth) {
38
+ const kind = item.kind === 'deck' ? item.interactionKind ?? 'decision' : 'review';
39
+ const icon = KIND_ICON[kind] ?? '·';
40
+ // Source and title share the row budget: an unbounded source (a long node
41
+ // or session name) would floor the title at its minimum and overflow the
42
+ // column, bending the panel divider. Shrink the source only when the row
43
+ // cannot hold it alongside the title's 10-col minimum.
44
+ const rawSource = item.source.sessionName ?? item.source.askedBy ?? item.source.nodeId ?? '';
45
+ const age = formatTimeAgo(item.blockedSince);
46
+ const source = truncateRow(rawSource, Math.max(8, contentWidth - age.length - 8 - 10));
47
+ const cursor = index === selectedIndex ? `${CYAN}▸${RESET} ` : ' ';
48
+ const titleWidth = Math.max(10, contentWidth - source.length - age.length - 8);
49
+ let row = `${cursor}${ansiColor(icon, KIND_COLOR[kind] ?? 'cyan')} `;
50
+ if (source)
51
+ row += `${ansiColor(source, 'yellow')} ${DIM}·${RESET} `;
52
+ row += `${BOLD}${truncateRow(item.title || `(${item.id.slice(0, 8)})`, titleWidth)}${RESET} ${DIM}${age}${RESET}`;
53
+ if (item.claim)
54
+ return [row, ` ${DIM}${truncateRow(`claimed by ${item.claim.owner}`, contentWidth - 6)}${RESET}`];
55
+ if (item.subtitle)
56
+ return [row, ` ${DIM}${truncateRow(item.subtitle, contentWidth - 6)}${RESET}`];
57
+ return [row];
58
+ }
59
+ /** Select a contiguous row window that always contains the selected ticket. */
60
+ function visibleWindow(rows, selectedIndex, capacity) {
61
+ let start = Math.max(0, Math.min(selectedIndex, rows.length - 1));
62
+ let end = start + 1;
63
+ let preferBelow = true;
64
+ while (true) {
65
+ const below = end < rows.length ? { start, end: end + 1 } : undefined;
66
+ const above = start > 0 ? { start: start - 1, end } : undefined;
67
+ const preferred = preferBelow ? [below, above] : [above, below];
68
+ const next = preferred.find((candidate) => candidate !== undefined && windowHeight(rows, candidate.start, candidate.end) <= capacity);
69
+ if (next === undefined)
70
+ break;
71
+ start = next.start;
72
+ end = next.end;
73
+ preferBelow = !preferBelow;
74
+ }
75
+ return { start, end };
76
+ }
77
+ function windowHeight(rows, start, end) {
78
+ let height = start > 0 ? 1 : 0;
79
+ for (let index = start; index < end; index++)
80
+ height += rows[index].length;
81
+ if (end < rows.length)
82
+ height++;
83
+ return height;
84
+ }
package/dist/index.d.ts CHANGED
@@ -13,6 +13,8 @@ export type { CompletionHandler, InboxRootRegistration, InboxRootStatus, Registe
13
13
  export { submitDeck, submitReview, readTicketResult, finalizeDeck, finalizeReview, completeDeck, completeReview, cancelTicket, cancelTicketResult, } from './inbox/tickets.js';
14
14
  export type { SubmitDeckOptions, SubmitReviewOptions } from './inbox/tickets.js';
15
15
  export { dispatchCompletion, reconcileCompletions } from './inbox/completion.js';
16
+ export { requestFollowUp, cancelFollowUp, submitFollowUpResult, readFollowUp } from './inbox/followup.js';
17
+ export type { FollowUpRequestEvent, FollowUpRequest, FollowUpResult } from './inbox/followup.js';
16
18
  export { claimTicket, heartbeatClaim, releaseClaim, readTicketClaim } from './inbox/claim.js';
17
19
  export type { TicketClaim, ClaimOptions } from './inbox/claim.js';
18
20
  export { renderMarkdown, checkMarkdown, ensureRenderer, isRendererReady, } from './render/termrender.js';
@@ -21,6 +23,6 @@ export { notifyDeck } from './inbox/deck-factories.js';
21
23
  export type { NotifyDeckOpts } from './inbox/deck-factories.js';
22
24
  export { deckPath, reviewPath, responsePath, progressPath, visualsDir, interactionState, isResolved, isClaimed, atomicWriteJson, readJson, writeResponse, writeProgress, clearProgress, } from './inbox/convention.js';
23
25
  export type { InteractionState } from './inbox/convention.js';
24
- export type { Interaction, InteractionOption, InteractionResponse, InteractionKind, Deck, DeckSource, MountedPanel, MountedPanelOpts, GenerateVisual, VisualBlock, FeedbackComment, FeedbackResult, ResolutionEnvelope, InboxItem, DisplayOpts, ClaimSummary, TicketSummary, DeckTicketSummary, ReviewTicketSummary, ReviewDescriptor, DeckTicketResult, ReviewTicketResult, CanceledTicketResult, TicketResult, CompletionEvent, } from './types.js';
26
+ export type { Interaction, InteractionOption, InteractionResponse, InteractionKind, Deck, DeckSource, MountedPanel, MountedPanelOpts, GenerateVisual, VisualBlock, FollowUpState, FeedbackComment, FeedbackResult, ResolutionEnvelope, InboxItem, DisplayOpts, ClaimSummary, TicketSummary, DeckTicketSummary, ReviewTicketSummary, ReviewDescriptor, DeckTicketResult, ReviewTicketResult, CanceledTicketResult, TicketResult, CompletionEvent, } from './types.js';
25
27
  export type { Key } from './tui/terminal.js';
26
28
  export type { ConversationMessage } from './conversation/reader.js';
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ export { scanInbox } from './inbox/scan.js';
11
11
  export { registerInboxRoot, unregisterInboxRoot, listInboxRoots, managedInboxRoot } from './inbox/registry.js';
12
12
  export { submitDeck, submitReview, readTicketResult, finalizeDeck, finalizeReview, completeDeck, completeReview, cancelTicket, cancelTicketResult, } from './inbox/tickets.js';
13
13
  export { dispatchCompletion, reconcileCompletions } from './inbox/completion.js';
14
+ export { requestFollowUp, cancelFollowUp, submitFollowUpResult, readFollowUp } from './inbox/followup.js';
14
15
  export { claimTicket, heartbeatClaim, releaseClaim, readTicketClaim } from './inbox/claim.js';
15
16
  // Renderer binding — the sole org-wide termrender caller. Consumers
16
17
  // (sisyphus md-render / ask-schema) route markdown through these.