@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.
- package/dist/browser/server.d.ts +15 -5
- package/dist/browser/server.js +110 -14
- package/dist/cli.js +0 -0
- package/dist/conversation/reader.d.ts +15 -1
- package/dist/conversation/reader.js +320 -30
- package/dist/editor/roundtrip.d.ts +5 -0
- package/dist/editor/roundtrip.js +36 -0
- package/dist/inbox/completion.js +1 -27
- package/dist/inbox/controller.d.ts +36 -1
- package/dist/inbox/controller.js +335 -28
- package/dist/inbox/convention.d.ts +7 -0
- package/dist/inbox/convention.js +32 -0
- package/dist/inbox/deck-adapter.d.ts +10 -2
- package/dist/inbox/deck-adapter.js +23 -12
- package/dist/inbox/deck-schema.d.ts +2 -0
- package/dist/inbox/deck-schema.js +4 -4
- package/dist/inbox/followup.d.ts +57 -0
- package/dist/inbox/followup.js +117 -0
- package/dist/inbox/registry.d.ts +2 -0
- package/dist/inbox/registry.js +6 -2
- package/dist/inbox/tickets.js +8 -2
- package/dist/inbox/tui.d.ts +1 -1
- package/dist/inbox/tui.js +58 -25
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/render/termrender.js +25 -26
- package/dist/tui/app.d.ts +1 -1
- package/dist/tui/app.js +80 -88
- package/dist/tui/input.d.ts +5 -1
- package/dist/tui/input.js +41 -15
- package/dist/tui/log.d.ts +2 -0
- package/dist/tui/log.js +53 -0
- package/dist/tui/render.js +42 -23
- package/dist/tui/terminal.d.ts +1 -0
- package/dist/tui/terminal.js +5 -0
- package/dist/tui/tmux.js +44 -22
- package/dist/types.d.ts +39 -2
- package/dist/visuals/conversation.d.ts +7 -0
- package/dist/visuals/conversation.js +16 -0
- package/dist/visuals/generate.d.ts +3 -1
- package/dist/visuals/generate.js +8 -6
- package/dist/web/assets/{index-DhbBiRqS.js → index-YFwgZZMg.js} +2 -2
- package/dist/web/index.html +1 -1
- package/package.json +7 -2
package/dist/inbox/convention.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
+
}
|
package/dist/inbox/registry.d.ts
CHANGED
|
@@ -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. */
|
package/dist/inbox/registry.js
CHANGED
|
@@ -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 {
|
|
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;
|
package/dist/inbox/tickets.js
CHANGED
|
@@ -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) {
|
package/dist/inbox/tui.d.ts
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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.
|