@crouton-kit/humanloop 0.3.33 → 0.3.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/api.d.ts +4 -8
  2. package/dist/api.js +6 -29
  3. package/dist/browser/server.d.ts +3 -3
  4. package/dist/browser/server.js +22 -25
  5. package/dist/cli.js +126 -1114
  6. package/dist/editor/feedback.d.ts +9 -0
  7. package/dist/editor/feedback.js +23 -0
  8. package/dist/editor/review.d.ts +8 -30
  9. package/dist/editor/review.js +79 -118
  10. package/dist/inbox/claim.d.ts +23 -0
  11. package/dist/inbox/claim.js +67 -0
  12. package/dist/inbox/completion.d.ts +4 -0
  13. package/dist/inbox/completion.js +100 -0
  14. package/dist/inbox/controller.d.ts +57 -0
  15. package/dist/inbox/controller.js +311 -0
  16. package/dist/inbox/convention.d.ts +15 -10
  17. package/dist/inbox/convention.js +149 -79
  18. package/dist/inbox/deck-adapter.d.ts +27 -0
  19. package/dist/inbox/deck-adapter.js +57 -0
  20. package/dist/inbox/deck-schema.d.ts +18 -14
  21. package/dist/inbox/deck-schema.js +59 -117
  22. package/dist/inbox/layout.d.ts +8 -0
  23. package/dist/inbox/layout.js +9 -0
  24. package/dist/inbox/registry.d.ts +29 -0
  25. package/dist/inbox/registry.js +97 -0
  26. package/dist/inbox/review-adapter.d.ts +22 -0
  27. package/dist/inbox/review-adapter.js +56 -0
  28. package/dist/inbox/scan.d.ts +3 -2
  29. package/dist/inbox/scan.js +71 -43
  30. package/dist/inbox/tickets.d.ts +63 -0
  31. package/dist/inbox/tickets.js +247 -0
  32. package/dist/inbox/tui.d.ts +3 -6
  33. package/dist/inbox/tui.js +24 -112
  34. package/dist/index.d.ts +12 -3
  35. package/dist/index.js +8 -2
  36. package/dist/summary.d.ts +2 -3
  37. package/dist/summary.js +2 -3
  38. package/dist/surfaces/inbox-popup.d.ts +2 -0
  39. package/dist/surfaces/inbox-popup.js +32 -0
  40. package/dist/tui/app.js +13 -0
  41. package/dist/tui/tmux.d.ts +30 -7
  42. package/dist/tui/tmux.js +179 -66
  43. package/dist/types.d.ts +68 -7
  44. package/package.json +2 -2
@@ -1,62 +1,90 @@
1
- import { readdirSync, statSync } from 'fs';
2
- import { resolve, basename } from 'path';
3
- import { deckPath, isResolved, isClaimed, readJson } from './convention.js';
4
- // ── scanInbox ─────────────────────────────────────────────────────────────────
1
+ import { readdirSync, realpathSync, statSync } from 'node:fs';
2
+ import { basename, resolve } from 'node:path';
3
+ import { claimPath, deckPath, isResolved, readJson, reviewPath } from './convention.js';
4
+ import { validateDeck, validateReviewDescriptor } from './deck-schema.js';
5
+ import { listInboxRoots } from './registry.js';
6
+ function claimSummary(dir) {
7
+ const claim = readJson(claimPath(dir));
8
+ if (claim === null || typeof claim.host !== 'string' || typeof claim.claimedAt !== 'string' || typeof claim.heartbeatAt !== 'string')
9
+ return undefined;
10
+ return { owner: claim.host, claimedAt: claim.claimedAt, heartbeatAt: claim.heartbeatAt };
11
+ }
12
+ function deckSummary(dir, id) {
13
+ const raw = readJson(deckPath(dir));
14
+ if (raw === null)
15
+ return null;
16
+ let deck;
17
+ try {
18
+ deck = validateDeck(raw);
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ const first = deck.interactions[0];
24
+ if (first === undefined)
25
+ return null;
26
+ let blockedSince = deck.source?.blockedSince;
27
+ if (blockedSince === undefined) {
28
+ try {
29
+ blockedSince = statSync(deckPath(dir)).mtime.toISOString();
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ return { dir, id, kind: 'deck', title: deck.title ?? first.title, subtitle: first.subtitle, interactionKind: first.kind, source: deck.source ?? {}, blockedSince, claim: claimSummary(dir) };
36
+ }
37
+ function reviewSummary(dir, id) {
38
+ const raw = readJson(reviewPath(dir));
39
+ if (raw === null)
40
+ return null;
41
+ let review;
42
+ try {
43
+ review = validateReviewDescriptor(raw);
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ return { dir, id, kind: 'review', title: review.title, file: review.file, output: review.output, source: review.source, blockedSince: review.blockedSince, claim: claimSummary(dir) };
49
+ }
50
+ /** Read all unresolved deck/review tickets, newest first. Progress never affects visibility. */
5
51
  export function scanInbox(roots) {
52
+ const selectedRoots = roots ?? listInboxRoots().filter((root) => root.available).map((root) => root.root);
53
+ const seen = new Set();
6
54
  const items = [];
7
- for (const root of roots) {
55
+ for (const root of selectedRoots) {
56
+ let canonicalRoot;
57
+ try {
58
+ canonicalRoot = realpathSync(root);
59
+ }
60
+ catch {
61
+ continue;
62
+ }
8
63
  let entries;
9
64
  try {
10
- entries = readdirSync(root);
65
+ entries = readdirSync(canonicalRoot);
11
66
  }
12
67
  catch {
13
- // root doesn't exist or isn't readable — skip silently
14
68
  continue;
15
69
  }
16
70
  for (const entry of entries) {
17
- const dir = resolve(root, entry);
71
+ const dir = resolve(canonicalRoot, entry);
72
+ let canonicalDir;
18
73
  try {
19
- const stat = statSync(dir);
20
- if (!stat.isDirectory())
74
+ if (!statSync(dir).isDirectory())
21
75
  continue;
76
+ canonicalDir = realpathSync(dir);
22
77
  }
23
78
  catch {
24
79
  continue;
25
80
  }
26
- // Skip resolved or actively claimed dirs
27
- if (isResolved(dir) || isClaimed(dir))
28
- continue;
29
- const dp = deckPath(dir);
30
- const deck = readJson(dp);
31
- if (deck === null)
81
+ if (resolve(canonicalDir, '..') !== canonicalRoot || seen.has(canonicalDir) || isResolved(canonicalDir))
32
82
  continue;
33
- // Derive blockedSince: prefer deck.source.blockedSince, fall back to mtime
34
- let blockedSince;
35
- if (deck.source?.blockedSince !== undefined) {
36
- blockedSince = deck.source.blockedSince;
37
- }
38
- else {
39
- try {
40
- blockedSince = new Date(statSync(dp).mtime).toISOString();
41
- }
42
- catch {
43
- blockedSince = new Date().toISOString();
44
- }
45
- }
46
- const firstInteraction = deck.interactions[0];
47
- const item = {
48
- dir,
49
- id: firstInteraction?.id ?? basename(dir),
50
- title: deck.title ?? firstInteraction?.title,
51
- subtitle: firstInteraction?.subtitle,
52
- kind: firstInteraction?.kind,
53
- source: deck.source,
54
- blockedSince,
55
- };
56
- items.push(item);
83
+ seen.add(canonicalDir);
84
+ const item = deckSummary(canonicalDir, basename(canonicalDir)) ?? reviewSummary(canonicalDir, basename(canonicalDir));
85
+ if (item !== null)
86
+ items.push(item);
57
87
  }
58
88
  }
59
- // Sort ascending by blockedSince (ISO string compare is monotonic)
60
- items.sort((a, b) => (a.blockedSince < b.blockedSince ? -1 : a.blockedSince > b.blockedSince ? 1 : 0));
61
- return items;
89
+ return items.sort((a, b) => b.blockedSince.localeCompare(a.blockedSince) || a.id.localeCompare(b.id));
62
90
  }
@@ -0,0 +1,63 @@
1
+ import type { Deck, DeckTicketResult, FeedbackResult, ReviewDescriptor, TicketResult } from '../types.js';
2
+ /** Strict decoder for the only canonical final marker. */
3
+ export declare function readTicketResult(dirOrResponsePath: string): TicketResult | null;
4
+ export interface SubmitDeckOptions {
5
+ root: string;
6
+ id: string;
7
+ deck: Deck;
8
+ }
9
+ export declare function submitDeck(opts: SubmitDeckOptions): {
10
+ id: string;
11
+ dir: string;
12
+ kind: 'deck';
13
+ };
14
+ export interface SubmitReviewOptions {
15
+ root: string;
16
+ id: string;
17
+ review: Omit<ReviewDescriptor, 'schema' | 'file' | 'output' | 'blockedSince'> & {
18
+ file: string;
19
+ output?: string;
20
+ blockedSince?: string;
21
+ };
22
+ }
23
+ export declare function submitReview(opts: SubmitReviewOptions): {
24
+ id: string;
25
+ dir: string;
26
+ kind: 'review';
27
+ };
28
+ export declare function finalizeDeck(dir: string, responses: DeckTicketResult['responses'], claimToken: string, completedAt?: string): {
29
+ won: boolean;
30
+ result: TicketResult;
31
+ };
32
+ export declare function finalizeReview(dir: string, feedback: FeedbackResult, claimToken: string, completedAt?: string): {
33
+ won: boolean;
34
+ result: TicketResult;
35
+ descriptor: ReviewDescriptor;
36
+ };
37
+ export declare function cancelTicketResult(dir: string, opts?: {
38
+ reason?: string;
39
+ actor?: string;
40
+ }): {
41
+ status: 'canceled' | 'already_resolved';
42
+ result: TicketResult;
43
+ };
44
+ export declare function ticketRoot(dir: string): string | null;
45
+ /** Finalize and immediately cross the trusted root completion boundary. */
46
+ export declare function completeDeck(dir: string, responses: DeckTicketResult['responses'], claimToken: string): Promise<{
47
+ won: boolean;
48
+ result: TicketResult;
49
+ }>;
50
+ /** Finalize a review, then let completion own its projection and notification boundary. */
51
+ export declare function completeReview(dir: string, feedback: FeedbackResult, claimToken: string): Promise<{
52
+ won: boolean;
53
+ result: TicketResult;
54
+ descriptor: ReviewDescriptor;
55
+ }>;
56
+ /** Cancellation races finalization but never requires or removes another claim. */
57
+ export declare function cancelTicket(dir: string, opts?: {
58
+ reason?: string;
59
+ actor?: string;
60
+ }): Promise<{
61
+ status: 'canceled' | 'already_resolved';
62
+ result: TicketResult;
63
+ }>;
@@ -0,0 +1,247 @@
1
+ import { existsSync, linkSync, mkdirSync, readFileSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { basename, dirname, isAbsolute, resolve } from 'node:path';
3
+ import { z } from 'zod';
4
+ import { buildSummary } from '../summary.js';
5
+ import { clearProgress, claimPath, deckPath, deliveryErrorPath, deliveryPath, progressPath, responsePath, reviewPath } from './convention.js';
6
+ import { validateDeck, validateReviewDescriptor, validateReviewProjection, resolveDeckBodyPaths } from './deck-schema.js';
7
+ import { registeredInboxRoot } from './registry.js';
8
+ import { readTicketClaim, releaseClaimLocked, withTicketLock } from './claim.js';
9
+ import { dispatchCompletion } from './completion.js';
10
+ const ticketId = z.string().regex(/^[A-Za-z0-9_-]+$/).min(1).max(128);
11
+ 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
+ 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();
13
+ const iso = z.string().datetime({ offset: true });
14
+ const feedbackSchema = z.object({ file: z.string().min(1).refine(isAbsolute, 'file must be absolute').refine((file) => resolve(file) === file, 'file must be canonical'), submitted: z.literal(true), approved: z.boolean(), comments: z.array(feedbackCommentSchema), submittedAt: iso, savedAt: iso }).strict().superRefine((result, ctx) => {
15
+ if (result.approved !== (result.comments.length === 0))
16
+ ctx.addIssue({ code: 'custom', message: 'approved must match empty comments' });
17
+ result.comments.forEach((comment, index) => {
18
+ if (comment.endLine < comment.line)
19
+ ctx.addIssue({ code: 'custom', message: 'endLine must be >= line', path: ['comments', index, 'endLine'] });
20
+ if ((comment.colStart === undefined) !== (comment.colEnd === undefined))
21
+ ctx.addIssue({ code: 'custom', message: 'columns must be supplied together', path: ['comments', index] });
22
+ if (comment.line === comment.endLine && comment.colStart !== undefined && comment.colEnd !== undefined && comment.colEnd <= comment.colStart)
23
+ ctx.addIssue({ code: 'custom', message: 'colEnd must exceed colStart', path: ['comments', index, 'colEnd'] });
24
+ });
25
+ });
26
+ const deckResultSchema = z.object({ schema: z.literal('humanloop.response/v2'), kind: z.literal('deck'), responses: z.array(responseSchema), summary: z.string(), completedAt: iso }).strict();
27
+ const reviewResultSchema = z.object({ schema: z.literal('humanloop.review-response/v1'), kind: z.literal('review'), result: feedbackSchema, completedAt: iso }).strict();
28
+ const canceledResultSchema = z.object({ schema: z.literal('humanloop.cancel/v1'), kind: z.literal('canceled'), canceledAt: iso, reason: z.string().optional(), actor: z.string().optional() }).strict();
29
+ /** Strict decoder for the only canonical final marker. */
30
+ export function readTicketResult(dirOrResponsePath) {
31
+ const path = basename(dirOrResponsePath) === 'response.json' ? dirOrResponsePath : responsePath(dirOrResponsePath);
32
+ let raw;
33
+ try {
34
+ raw = JSON.parse(readFileSync(path, 'utf8'));
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ const parsed = deckResultSchema.safeParse(raw);
40
+ if (parsed.success)
41
+ return parsed.data;
42
+ const review = reviewResultSchema.safeParse(raw);
43
+ if (review.success)
44
+ return review.data;
45
+ const canceled = canceledResultSchema.safeParse(raw);
46
+ return canceled.success ? canceled.data : null;
47
+ }
48
+ function rootAndDir(root, id) {
49
+ const registration = registeredInboxRoot(root);
50
+ if (registration === null)
51
+ throw new Error('interaction root is not registered');
52
+ const parsedId = ticketId.parse(id);
53
+ const dir = resolve(registration.root, parsedId);
54
+ if (dirname(dir) !== registration.root)
55
+ throw new Error('ticket directory must be a direct child of its registered root');
56
+ return { root: registration.root, dir };
57
+ }
58
+ function ticketDir(root, id) {
59
+ const candidate = rootAndDir(root, id);
60
+ let created = false;
61
+ if (!existsSync(candidate.dir)) {
62
+ mkdirSync(candidate.dir, { mode: 0o700 });
63
+ created = true;
64
+ }
65
+ const dir = realpathSync(candidate.dir);
66
+ if (dirname(dir) !== candidate.root)
67
+ throw new Error('ticket directory must canonically be a direct child of its registered root');
68
+ return { dir, created };
69
+ }
70
+ function discardCreatedTicket(dir, created) { if (created)
71
+ rmSync(dir, { recursive: true, force: true }); }
72
+ function hasTicketProtocolState(dir) {
73
+ return [deckPath(dir), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir), deliveryPath(dir), deliveryErrorPath(dir)].some(existsSync);
74
+ }
75
+ function requireRegisteredTicket(dir) {
76
+ const canonical = realpathSync(dir);
77
+ const root = dirname(canonical);
78
+ const registration = registeredInboxRoot(root);
79
+ if (registration === null || registration.root !== root || basename(canonical) === '')
80
+ throw new Error('ticket must be a canonical direct child of a registered root');
81
+ if (!existsSync(deckPath(canonical)) && !existsSync(reviewPath(canonical)))
82
+ throw new Error('ticket has no request descriptor');
83
+ return { root, dir: canonical };
84
+ }
85
+ 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
+ }
100
+ }
101
+ export function submitDeck(opts) {
102
+ // Validate shape before mutating caller-owned interaction directories.
103
+ validateDeck(opts.deck);
104
+ const { dir, created } = ticketDir(opts.root, opts.id);
105
+ try {
106
+ if (hasTicketProtocolState(dir))
107
+ throw new Error(`ticket protocol state already exists: ${dir}`);
108
+ const deck = validateDeck(resolveDeckBodyPaths(opts.deck, dir));
109
+ const stamped = { ...deck, source: { ...(deck.source ?? {}), blockedSince: deck.source?.blockedSince ?? new Date().toISOString() } };
110
+ publishRequest(deckPath(dir), stamped);
111
+ return { id: opts.id, dir, kind: 'deck' };
112
+ }
113
+ catch (error) {
114
+ discardCreatedTicket(dir, created);
115
+ throw error;
116
+ }
117
+ }
118
+ export function submitReview(opts) {
119
+ if (!isAbsolute(opts.review.file) || !existsSync(opts.review.file))
120
+ throw new Error('review file must be an existing absolute markdown file');
121
+ if (!/\.md(?:own)?$/i.test(opts.review.file))
122
+ throw new Error('review file must be markdown');
123
+ const source = realpathSync(opts.review.file);
124
+ const { dir, created } = ticketDir(opts.root, opts.id);
125
+ try {
126
+ if (hasTicketProtocolState(dir))
127
+ throw new Error(`ticket protocol state already exists: ${dir}`);
128
+ const descriptor = validateReviewProjection(dir, { schema: 'humanloop.review/v1', file: source, output: resolve(opts.review.output ?? `${dir}/feedback.json`), title: opts.review.title, source: opts.review.source, blockedSince: opts.review.blockedSince ?? new Date().toISOString() });
129
+ publishRequest(reviewPath(dir), descriptor);
130
+ return { id: opts.id, dir, kind: 'review' };
131
+ }
132
+ catch (error) {
133
+ discardCreatedTicket(dir, created);
134
+ throw error;
135
+ }
136
+ }
137
+ 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
+ }
153
+ }
154
+ function requireDeck(dir) { return validateDeck(JSON.parse(readFileSync(deckPath(dir), 'utf8'))); }
155
+ function requireReview(dir) { return validateReviewDescriptor(JSON.parse(readFileSync(reviewPath(dir), 'utf8'))); }
156
+ function validateDeckResponses(deck, raw) {
157
+ const responses = z.array(responseSchema).parse(raw);
158
+ const interactions = new Map(deck.interactions.map((interaction) => [interaction.id, interaction]));
159
+ const seen = new Set();
160
+ for (const response of responses) {
161
+ const interaction = interactions.get(response.id);
162
+ if (interaction === undefined || seen.has(response.id))
163
+ throw new Error(`response does not match a unique deck interaction: ${response.id}`);
164
+ seen.add(response.id);
165
+ const optionIds = new Set(interaction.options.map((option) => option.id));
166
+ if (response.selectedOptionId !== undefined && (!optionIds.has(response.selectedOptionId) || interaction.multiSelect === true))
167
+ throw new Error(`invalid single-select response for ${response.id}`);
168
+ if (response.selectedOptionIds !== undefined && (interaction.multiSelect !== true || response.selectedOptionIds.some((id) => !optionIds.has(id)) || new Set(response.selectedOptionIds).size !== response.selectedOptionIds.length))
169
+ throw new Error(`invalid multi-select response for ${response.id}`);
170
+ if (response.freetext !== undefined && interaction.allowFreetext !== true)
171
+ throw new Error(`freetext is not allowed for ${response.id}`);
172
+ if (response.optionComments !== undefined && (interaction.multiSelect !== true || Object.keys(response.optionComments).some((id) => !optionIds.has(id))))
173
+ throw new Error(`invalid option comments for ${response.id}`);
174
+ }
175
+ return responses;
176
+ }
177
+ function requireClaimOwnership(dir, token) {
178
+ if (readTicketClaim(dir)?.token !== token)
179
+ throw new Error('only the current claim holder may submit a result');
180
+ }
181
+ function clearOwnedWork(dir, claimToken) { clearProgress(dir); releaseClaimLocked(dir, claimToken); }
182
+ export function finalizeDeck(dir, responses, claimToken, completedAt = new Date().toISOString()) {
183
+ const ticket = requireRegisteredTicket(dir);
184
+ return withTicketLock(ticket.dir, () => {
185
+ requireClaimOwnership(ticket.dir, claimToken);
186
+ const deck = requireDeck(ticket.dir);
187
+ const parsedResponses = validateDeckResponses(deck, responses);
188
+ const result = { schema: 'humanloop.response/v2', kind: 'deck', responses: parsedResponses, summary: buildSummary(deck, parsedResponses), completedAt };
189
+ const won = exclusiveResult(ticket.dir, result);
190
+ clearOwnedWork(ticket.dir, claimToken);
191
+ return { won, result: won ? result : readTicketResult(ticket.dir) ?? result };
192
+ });
193
+ }
194
+ export function finalizeReview(dir, feedback, claimToken, completedAt = new Date().toISOString()) {
195
+ const ticket = requireRegisteredTicket(dir);
196
+ return withTicketLock(ticket.dir, () => {
197
+ requireClaimOwnership(ticket.dir, claimToken);
198
+ const descriptor = requireReview(ticket.dir);
199
+ const parsed = feedbackSchema.parse(feedback);
200
+ if (realpathSync(parsed.file) !== descriptor.file)
201
+ throw new Error('review result file does not match descriptor');
202
+ const result = { schema: 'humanloop.review-response/v1', kind: 'review', result: parsed, completedAt };
203
+ const won = exclusiveResult(ticket.dir, result);
204
+ clearOwnedWork(ticket.dir, claimToken);
205
+ return { won, result: won ? result : readTicketResult(ticket.dir) ?? result, descriptor };
206
+ });
207
+ }
208
+ export function cancelTicketResult(dir, opts = {}) {
209
+ const ticket = requireRegisteredTicket(dir);
210
+ return withTicketLock(ticket.dir, () => {
211
+ const result = { schema: 'humanloop.cancel/v1', kind: 'canceled', canceledAt: new Date().toISOString(), ...(opts.reason === undefined ? {} : { reason: opts.reason }), ...(opts.actor === undefined ? {} : { actor: opts.actor }) };
212
+ const won = exclusiveResult(ticket.dir, result);
213
+ return { status: won ? 'canceled' : 'already_resolved', result: won ? result : readTicketResult(ticket.dir) ?? result };
214
+ });
215
+ }
216
+ export function ticketRoot(dir) {
217
+ try {
218
+ return requireRegisteredTicket(dir).root;
219
+ }
220
+ catch {
221
+ return null;
222
+ }
223
+ }
224
+ /** Finalize and immediately cross the trusted root completion boundary. */
225
+ export async function completeDeck(dir, responses, claimToken) {
226
+ const completed = finalizeDeck(dir, responses, claimToken);
227
+ const root = ticketRoot(dir);
228
+ if (completed.won && root !== null)
229
+ await dispatchCompletion(root, dir);
230
+ return completed;
231
+ }
232
+ /** Finalize a review, then let completion own its projection and notification boundary. */
233
+ export async function completeReview(dir, feedback, claimToken) {
234
+ const completed = finalizeReview(dir, feedback, claimToken);
235
+ const root = ticketRoot(dir);
236
+ if (completed.won && root !== null)
237
+ await dispatchCompletion(root, dir);
238
+ return completed;
239
+ }
240
+ /** Cancellation races finalization but never requires or removes another claim. */
241
+ export async function cancelTicket(dir, opts = {}) {
242
+ const canceled = cancelTicketResult(dir, opts);
243
+ const root = ticketRoot(dir);
244
+ if (canceled.status === 'canceled' && root !== null)
245
+ await dispatchCompletion(root, dir);
246
+ return canceled;
247
+ }
@@ -1,9 +1,6 @@
1
- import type { InboxItem } from '../types.js';
1
+ import type { TicketSummary } from '../types.js';
2
2
  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
- export declare function buildInboxLines(items: InboxItem[], width: number, selectedIndex: number): string[];
6
- export declare function pickFromInbox(items: InboxItem[], opts: {
7
- cols: number;
8
- rows: number;
9
- }): Promise<InboxItem | null>;
5
+ /** Pure inbox rows; selection and terminal ownership belong to InboxController. */
6
+ export declare function buildInboxLines(items: TicketSummary[], width: number, selectedIndex: number): string[];
package/dist/inbox/tui.js CHANGED
@@ -1,7 +1,4 @@
1
- import { setupTerminal, restoreTerminal, parseKeypress, getTerminalSize, } from '../tui/terminal.js';
2
- import { diffFrame } from '../tui/render.js';
3
1
  import { RESET, BOLD, DIM, ITALIC, CYAN, RED, GRAY, YELLOW, truncateRow } from '../tui/ansi.js';
4
- // ── ANSI helpers (local to this module) ──────────────────────────────────────
5
2
  function ansiColor(text, color) {
6
3
  switch (color) {
7
4
  case 'gray': return `${GRAY}${text}${RESET}`;
@@ -11,124 +8,39 @@ function ansiColor(text, color) {
11
8
  default: return text;
12
9
  }
13
10
  }
14
- // ── Row model (ported verbatim from sisyphus cross-session-inbox.ts:8-21) ────
15
- export const KIND_ICON = {
16
- notify: '✉',
17
- decision: '◆',
18
- context: '✎',
19
- error: '⚠',
20
- };
21
- export const KIND_COLOR = {
22
- notify: 'gray',
23
- decision: 'cyan',
24
- context: 'cyan',
25
- error: 'red',
26
- };
27
- // ── formatTimeAgo (ported from sisyphus src/tui/lib/format.ts:5-12) ──────────
11
+ export const KIND_ICON = { notify: '✉', decision: '◆', context: '✎', error: '⚠', review: '▤' };
12
+ export const KIND_COLOR = { notify: 'gray', decision: 'cyan', context: 'cyan', error: 'red', review: 'yellow' };
28
13
  export function formatTimeAgo(iso) {
29
- const diff = Date.now() - new Date(iso).getTime();
30
- const minutes = Math.floor(diff / 60000);
31
- const hours = Math.floor(minutes / 60);
32
- if (hours > 0)
33
- return `${hours}h ago`;
14
+ const minutes = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
15
+ if (minutes >= 60)
16
+ return `${Math.floor(minutes / 60)}h ago`;
34
17
  if (minutes > 0)
35
18
  return `${minutes}m ago`;
36
19
  return 'just now';
37
20
  }
38
- // ── buildInboxLines ───────────────────────────────────────────────────────────
21
+ /** Pure inbox rows; selection and terminal ownership belong to InboxController. */
39
22
  export function buildInboxLines(items, width, selectedIndex) {
40
- const lines = [];
41
- if (items.length === 0) {
42
- lines.push(` ${DIM}${ITALIC}No pending interactions${RESET}`);
43
- return lines;
44
- }
45
- lines.push(` ${BOLD}${items.length} pending${RESET}`);
46
- lines.push('');
23
+ if (items.length === 0)
24
+ return [` ${DIM}${ITALIC}No pending interactions${RESET}`];
25
+ const lines = [` ${BOLD}${items.length} pending${RESET}`, ''];
47
26
  const contentWidth = width - 4;
48
- for (let i = 0; i < items.length; i++) {
49
- const item = items[i];
50
- const kindKey = item.kind ?? '';
51
- const icon = kindKey in KIND_ICON ? KIND_ICON[kindKey] : '·';
52
- const iconColor = kindKey in KIND_COLOR ? KIND_COLOR[kindKey] : 'cyan';
53
- const sourceLabel = item.source?.sessionName ?? item.source?.askedBy ?? '';
54
- const titleText = item.title ?? `(${item.id.slice(0, 8)})`;
55
- const blocked = formatTimeAgo(item.blockedSince);
56
- const cursor = i === selectedIndex ? `${CYAN}▸${RESET} ` : ' ';
57
- const maxTitle = Math.max(10, contentWidth - sourceLabel.length - blocked.length - 8);
58
- let row = cursor;
59
- row += ansiColor(icon, iconColor);
60
- if (sourceLabel) {
61
- row += ` ${ansiColor(sourceLabel, 'yellow')}`;
62
- row += ` ${DIM}·${RESET} `;
63
- }
64
- else {
65
- row += ' ';
66
- }
67
- row += `${BOLD}${truncateRow(titleText, maxTitle)}${RESET}`;
68
- row += ` ${DIM}${blocked}${RESET}`;
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
+ const source = item.source.sessionName ?? item.source.askedBy ?? '';
32
+ const age = formatTimeAgo(item.blockedSince);
33
+ const cursor = index === selectedIndex ? `${CYAN}▸${RESET} ` : ' ';
34
+ const titleWidth = Math.max(10, contentWidth - source.length - age.length - 8);
35
+ let row = `${cursor}${ansiColor(icon, KIND_COLOR[kind] ?? 'cyan')} `;
36
+ if (source)
37
+ row += `${ansiColor(source, 'yellow')} ${DIM}·${RESET} `;
38
+ row += `${BOLD}${truncateRow(item.title || `(${item.id.slice(0, 8)})`, titleWidth)}${RESET} ${DIM}${age}${RESET}`;
69
39
  lines.push(row);
70
- if (item.subtitle) {
40
+ if (item.claim)
41
+ lines.push(` ${DIM}claimed by ${item.claim.owner}${RESET}`);
42
+ else if (item.subtitle)
71
43
  lines.push(` ${DIM}${truncateRow(item.subtitle, contentWidth - 6)}${RESET}`);
72
- }
73
44
  }
74
45
  return lines;
75
46
  }
76
- // ── pickFromInbox ─────────────────────────────────────────────────────────────
77
- export function pickFromInbox(items, opts) {
78
- if (items.length === 0)
79
- return Promise.resolve(null);
80
- return new Promise((resolve) => {
81
- let selectedIndex = 0;
82
- let prevFrame = [];
83
- let onData;
84
- const flush = () => {
85
- const { cols: currentCols, rows: currentRows } = getTerminalSize();
86
- const lines = buildInboxLines(items, currentCols, selectedIndex);
87
- const { writes, nextPrevFrame } = diffFrame(prevFrame, lines, currentRows, currentCols);
88
- process.stdout.write('\x1b[?2026h');
89
- for (const w of writes)
90
- process.stdout.write(w);
91
- process.stdout.write('\x1b[?2026l');
92
- prevFrame = nextPrevFrame;
93
- };
94
- // Resize reflows/scrolls what's already on screen, invalidating the diff
95
- // model — clear and redraw from scratch at the new size.
96
- const onResize = () => {
97
- prevFrame = [];
98
- process.stdout.write('\x1b[2J\x1b[H');
99
- flush();
100
- };
101
- const done = (result) => {
102
- restoreTerminal();
103
- process.stdin.removeListener('data', onData);
104
- process.stdout.removeListener('resize', onResize);
105
- resolve(result);
106
- };
107
- setupTerminal();
108
- flush();
109
- onData = (data) => {
110
- const { input, key } = parseKeypress(data);
111
- if (key.downArrow || input === 'j') {
112
- selectedIndex = Math.min(selectedIndex + 1, items.length - 1);
113
- flush();
114
- return;
115
- }
116
- if (key.upArrow || input === 'k') {
117
- selectedIndex = Math.max(selectedIndex - 1, 0);
118
- flush();
119
- return;
120
- }
121
- if (key.return) {
122
- const selected = items[selectedIndex];
123
- done(selected ?? null);
124
- return;
125
- }
126
- if (key.escape || (key.ctrl && input === 'c') || input === 'q') {
127
- done(null);
128
- return;
129
- }
130
- };
131
- process.stdin.on('data', onData);
132
- process.stdout.on('resize', onResize);
133
- });
134
- }
package/dist/index.d.ts CHANGED
@@ -3,15 +3,24 @@ export { defaultGenerateVisual } from './visuals/generate.js';
3
3
  export { launchReview } from './editor/review.js';
4
4
  export { launchReview as review } from './editor/review.js';
5
5
  export type { ReviewOptions } from './editor/review.js';
6
- export { ask, notify, inbox } from './api.js';
6
+ export { ask, notify, openInbox } from './api.js';
7
+ export { openInboxPopup } from './surfaces/inbox-popup.js';
8
+ export { toggleInboxPopup, installInboxBinding, inspectInboxBinding, unbindInboxBinding, inboxPopupStyle } from './tui/tmux.js';
7
9
  export { display } from './surfaces/display.js';
8
10
  export { scanInbox } from './inbox/scan.js';
11
+ export { registerInboxRoot, unregisterInboxRoot, listInboxRoots, managedInboxRoot } from './inbox/registry.js';
12
+ export type { CompletionHandler, InboxRootRegistration, InboxRootStatus, RegisterInboxRootOptions } from './inbox/registry.js';
13
+ export { submitDeck, submitReview, readTicketResult, finalizeDeck, finalizeReview, completeDeck, completeReview, cancelTicket, cancelTicketResult, } from './inbox/tickets.js';
14
+ export type { SubmitDeckOptions, SubmitReviewOptions } from './inbox/tickets.js';
15
+ export { dispatchCompletion, reconcileCompletions } from './inbox/completion.js';
16
+ export { claimTicket, heartbeatClaim, releaseClaim, readTicketClaim } from './inbox/claim.js';
17
+ export type { TicketClaim, ClaimOptions } from './inbox/claim.js';
9
18
  export { renderMarkdown, checkMarkdown, ensureRenderer, isRendererReady, } from './render/termrender.js';
10
19
  export { parseDeck, validateDeck, deckSchema, resolveDeckBodyPaths } from './inbox/deck-schema.js';
11
20
  export { notifyDeck } from './inbox/deck-factories.js';
12
21
  export type { NotifyDeckOpts } from './inbox/deck-factories.js';
13
- export { deckPath, responsePath, progressPath, visualsDir, interactionState, isResolved, isClaimed, atomicWriteJson, readJson, writeResponse, writeProgress, clearProgress, } from './inbox/convention.js';
22
+ export { deckPath, reviewPath, responsePath, progressPath, visualsDir, interactionState, isResolved, isClaimed, atomicWriteJson, readJson, writeResponse, writeProgress, clearProgress, } from './inbox/convention.js';
14
23
  export type { InteractionState } from './inbox/convention.js';
15
- export type { Interaction, InteractionOption, InteractionResponse, InteractionKind, Deck, DeckSource, MountedPanel, MountedPanelOpts, GenerateVisual, VisualBlock, FeedbackComment, FeedbackResult, ResolutionEnvelope, InboxItem, DisplayOpts, } from './types.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, InboxBindingState, CompletionEvent, } from './types.js';
16
25
  export type { Key } from './tui/terminal.js';
17
26
  export type { ConversationMessage } from './conversation/reader.js';