@crouton-kit/humanloop 0.3.39 → 0.4.1
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/api.d.ts +3 -2
- package/dist/api.js +2 -2
- package/dist/cli.js +6 -2
- package/dist/inbox/controller.d.ts +14 -8
- package/dist/inbox/controller.js +133 -40
- package/dist/inbox/convention.d.ts +2 -2
- package/dist/inbox/convention.js +19 -3
- package/dist/inbox/deck-adapter.d.ts +3 -3
- package/dist/inbox/deck-adapter.js +3 -3
- package/dist/inbox/deck-schema.d.ts +2 -2
- package/dist/inbox/deck-schema.js +4 -4
- package/dist/inbox/maintenance.d.ts +4 -0
- package/dist/inbox/maintenance.js +145 -0
- package/dist/inbox/registry.d.ts +8 -0
- package/dist/inbox/registry.js +17 -5
- package/dist/inbox/tickets.d.ts +5 -0
- package/dist/inbox/tickets.js +35 -36
- package/dist/inbox/visual.d.ts +130 -0
- package/dist/inbox/visual.js +747 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/surfaces/inbox-popup.d.ts +1 -1
- package/dist/surfaces/inbox-popup.js +2 -2
- package/dist/tui/app.d.ts +3 -6
- package/dist/tui/app.js +59 -18
- package/dist/tui/render.js +4 -4
- package/dist/tui/tmux.js +2 -1
- package/dist/types.d.ts +48 -11
- package/package.json +2 -3
- package/dist/conversation/reader.d.ts +0 -20
- package/dist/conversation/reader.js +0 -348
- package/dist/visuals/conversation.d.ts +0 -7
- package/dist/visuals/conversation.js +0 -16
- package/dist/visuals/generate.d.ts +0 -11
- package/dist/visuals/generate.js +0 -81
package/dist/api.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { Deck, ResolutionEnvelope } from './types.js';
|
|
1
|
+
import type { Deck, ResolutionEnvelope, VisualProvider } from './types.js';
|
|
2
2
|
export interface AskOpts {
|
|
3
3
|
/** Interaction directory. Defaults to a managed temp dir under os.tmpdir(). */
|
|
4
4
|
dir?: string;
|
|
5
|
-
|
|
5
|
+
visualProvider?: VisualProvider;
|
|
6
6
|
cols?: number;
|
|
7
7
|
rows?: number;
|
|
8
8
|
}
|
|
@@ -18,6 +18,7 @@ export interface InboxOpts {
|
|
|
18
18
|
cols?: number;
|
|
19
19
|
rows?: number;
|
|
20
20
|
roots?: string[];
|
|
21
|
+
visualProvider?: VisualProvider;
|
|
21
22
|
}
|
|
22
23
|
/** Open the centralized inbox controller in the current human terminal. */
|
|
23
24
|
export declare function openInbox(opts?: InboxOpts): Promise<void>;
|
package/dist/api.js
CHANGED
|
@@ -27,7 +27,7 @@ export async function ask(deck, opts = {}) {
|
|
|
27
27
|
stampCanvasNode(deck);
|
|
28
28
|
atomicWriteJson(deckPath(dir), deck);
|
|
29
29
|
const { responses, completedAt, responsePath, deck: answeredDeck } = await resolveInteractionDir(dir, deck, {
|
|
30
|
-
|
|
30
|
+
visualProvider: opts.visualProvider,
|
|
31
31
|
cols: opts.cols,
|
|
32
32
|
rows: opts.rows,
|
|
33
33
|
});
|
|
@@ -48,6 +48,6 @@ export async function notify(title, body) {
|
|
|
48
48
|
}
|
|
49
49
|
/** Open the centralized inbox controller in the current human terminal. */
|
|
50
50
|
export async function openInbox(opts = {}) {
|
|
51
|
-
const controller = new InboxController({ roots: opts.roots, cols: opts.cols, rows: opts.rows });
|
|
51
|
+
const controller = new InboxController({ roots: opts.roots, cols: opts.cols, rows: opts.rows, visualProvider: opts.visualProvider });
|
|
52
52
|
await controller.run();
|
|
53
53
|
}
|
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { validateDeck } from './inbox/deck-schema.js';
|
|
|
7
7
|
import { managedInboxRoot, registerInboxRoot, registeredInboxRoot, unregisterInboxRoot, listInboxRoots } from './inbox/registry.js';
|
|
8
8
|
import { submitDeck, submitReview } from './inbox/tickets.js';
|
|
9
9
|
import { scanInbox } from './inbox/scan.js';
|
|
10
|
+
import { runInboxMaintenance } from './inbox/maintenance.js';
|
|
10
11
|
import { openInboxPopup } from './surfaces/inbox-popup.js';
|
|
11
12
|
import { toggleInboxPopup } from './tui/tmux.js';
|
|
12
13
|
import { ask } from './api.js';
|
|
@@ -47,11 +48,11 @@ function requireRegisteredRoot(root) {
|
|
|
47
48
|
const program = new Command();
|
|
48
49
|
program.name('hl').description('Humanloop durable inbox and review surface.').helpOption('-h, --help');
|
|
49
50
|
const inbox = program.command('inbox').description('Open, inspect, and configure the centralized human inbox.');
|
|
50
|
-
inbox.command('open').description('Open the inbox controller in this human TTY.').option('--root <path>', 'filter to a registered root', (value, prior) => [...prior, value], []).option('--control-socket <path>', 'popup control socket').action(async (options) => {
|
|
51
|
+
inbox.command('open').description('Open the inbox controller in this human TTY.').option('--root <path>', 'filter to a registered root', (value, prior) => [...prior, value], []).option('--control-socket <path>', 'popup control socket').option('--target-pane <pane>', 'tmux pane underneath the popup').action(async (options) => {
|
|
51
52
|
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
52
53
|
fail('hl inbox open requires an interactive TTY; use hl inbox list for read-only output');
|
|
53
54
|
try {
|
|
54
|
-
await openInboxPopup(options.controlSocket, roots(options.root));
|
|
55
|
+
await openInboxPopup(options.controlSocket, roots(options.root), options.targetPane);
|
|
55
56
|
}
|
|
56
57
|
catch (error) {
|
|
57
58
|
fail(error);
|
|
@@ -73,6 +74,9 @@ inbox.command('toggle').description('Toggle the inbox popup for one tmux client.
|
|
|
73
74
|
process.exit(result === 'failed' || result === 'ambiguous_client' ? 1 : 0);
|
|
74
75
|
});
|
|
75
76
|
inbox.command('list').description('Print pending tickets newest first as JSON.').option('--root <path>', 'filter to a root', (value, prior) => [...prior, value], []).action((options) => emit(scanInbox(roots(options.root))));
|
|
77
|
+
inbox.command('_maintain', { hidden: true }).requiredOption('--lease <path>').action(async (options) => {
|
|
78
|
+
await runInboxMaintenance(resolve(options.lease));
|
|
79
|
+
});
|
|
76
80
|
const root = inbox.command('roots').description('Manage durable interaction roots.');
|
|
77
81
|
root.command('register').description('Register a root owned by a host.').requiredOption('--root <path>').requiredOption('--owner <owner>').option('--handler-command <path>').option('--handler-arg <arg>', 'repeatable direct-exec handler argument', (value, prior) => [...prior, value], []).action((options) => {
|
|
78
82
|
if ((options.handlerCommand === undefined) !== (options.handlerArg.length === 0))
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import type { InteractionResponse, TicketSummary } from '../types.js';
|
|
1
|
+
import type { InteractionResponse, TicketSummary, VisualProvider } from '../types.js';
|
|
2
2
|
import type { Key } from '../tui/terminal.js';
|
|
3
3
|
import { startWebServer } from '../browser/server.js';
|
|
4
|
-
import { visualGeneratorForConversationSession } from '../visuals/conversation.js';
|
|
5
4
|
export interface InboxControllerOptions {
|
|
6
5
|
roots?: string[];
|
|
7
6
|
cols?: number;
|
|
@@ -10,8 +9,10 @@ export interface InboxControllerOptions {
|
|
|
10
9
|
completeDeck?: (dir: string, responses: InteractionResponse[], token: string) => Promise<unknown>;
|
|
11
10
|
startDeckBrowser?: typeof startWebServer;
|
|
12
11
|
openBrowser?: (url: string) => void;
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
visualProvider?: VisualProvider;
|
|
13
|
+
/** Pane underneath a tmux popup. Enables a registered focus handler to
|
|
14
|
+
* reveal the host surface that created the selected ticket. */
|
|
15
|
+
targetPane?: string;
|
|
15
16
|
}
|
|
16
17
|
type Screen = 'list' | 'detail';
|
|
17
18
|
/** One terminal owner for scanning, stable selection, claims, and frame diffs. */
|
|
@@ -35,10 +36,10 @@ export declare class InboxController {
|
|
|
35
36
|
private deckBrowserFinalizing;
|
|
36
37
|
private deckBrowserStarting;
|
|
37
38
|
private deckBrowserStartingGeneration;
|
|
39
|
+
private focusingSource;
|
|
38
40
|
/** Invalidates an in-flight asynchronous browser start when ownership ends. */
|
|
39
41
|
private deckBrowserGeneration;
|
|
40
42
|
private claim;
|
|
41
|
-
private reconciling;
|
|
42
43
|
private suspended;
|
|
43
44
|
private submittingDir;
|
|
44
45
|
private stdinListener;
|
|
@@ -91,14 +92,19 @@ export declare class InboxController {
|
|
|
91
92
|
/** Retake the TTY after the child exits and force a full repaint. */
|
|
92
93
|
private resumeAfterChild;
|
|
93
94
|
private resolvedRoots;
|
|
94
|
-
/**
|
|
95
|
-
|
|
96
|
-
private
|
|
95
|
+
/** Automatic ticket capability is deliberately marker + current-root-handler only. */
|
|
96
|
+
private visualProviderFor;
|
|
97
|
+
private startTicketVisual;
|
|
97
98
|
private leaveDetail;
|
|
98
99
|
private select;
|
|
99
100
|
private scrollPreview;
|
|
100
101
|
private detailSize;
|
|
101
102
|
private detailLines;
|
|
103
|
+
private focusAvailable;
|
|
104
|
+
/** Ask the registered host to reveal this ticket's source beside the pane
|
|
105
|
+
* underneath the popup. The popup closes only after the host acknowledges
|
|
106
|
+
* that focus succeeded, so a failure remains visible as status. */
|
|
107
|
+
private focusSelectedSource;
|
|
102
108
|
private passiveDetailLines;
|
|
103
109
|
private previewViewport;
|
|
104
110
|
private withStatus;
|
package/dist/inbox/controller.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync, watch } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
2
3
|
import { getTerminalSize, parseKeypress, restoreTerminal, setupTerminal } from '../tui/terminal.js';
|
|
3
4
|
import { diffFrame } from '../tui/render.js';
|
|
4
5
|
import { renderMarkdown } from '../render/termrender.js';
|
|
@@ -9,17 +10,17 @@ import { BOLD, CYAN, DIM, GRAY, RESET, YELLOW, clipLine } from '../tui/ansi.js';
|
|
|
9
10
|
import { buildInboxLines } from './tui.js';
|
|
10
11
|
import { inboxLayout } from './layout.js';
|
|
11
12
|
import { scanInbox } from './scan.js';
|
|
12
|
-
import { inboxRootsDirectory, listInboxRoots, registeredInboxRoot } from './registry.js';
|
|
13
|
+
import { inboxActivityPath, inboxRootsDirectory, inboxStateDirectory, listInboxRoots, registeredInboxRoot } from './registry.js';
|
|
13
14
|
import { claimTicket, heartbeatClaim, releaseClaim } from './claim.js';
|
|
14
15
|
import { completeDeck, readTicketResult, ticketRoot } from './tickets.js';
|
|
15
|
-
import {
|
|
16
|
-
import { clearProgress, deckPath, progressPath, readJson, responsePath, reviewPath } from './convention.js';
|
|
16
|
+
import { clearProgress, deckPath, progressPath, readJson, responsePath, reviewPath, runHandler, visualsDir } from './convention.js';
|
|
17
17
|
import { DeckAdapter } from './deck-adapter.js';
|
|
18
18
|
import { validateDeck } from './deck-schema.js';
|
|
19
19
|
import { ReviewAdapter } from './review-adapter.js';
|
|
20
|
-
import { visualGeneratorForConversationSession } from '../visuals/conversation.js';
|
|
21
20
|
import { editBufferInEditor } from '../editor/roundtrip.js';
|
|
22
21
|
import { cancelFollowUp, readFollowUp, requestFollowUp } from './followup.js';
|
|
22
|
+
import { cancelVisualRequest, readVisualResult, reconcileVisualRequestsForTicket, startVisualRequest, VISUAL_CAPABILITY, } from './visual.js';
|
|
23
|
+
import { kickInboxMaintenance } from './maintenance.js';
|
|
23
24
|
/** One terminal owner for scanning, stable selection, claims, and frame diffs. */
|
|
24
25
|
export class InboxController {
|
|
25
26
|
options;
|
|
@@ -41,10 +42,10 @@ export class InboxController {
|
|
|
41
42
|
deckBrowserFinalizing;
|
|
42
43
|
deckBrowserStarting = false;
|
|
43
44
|
deckBrowserStartingGeneration;
|
|
45
|
+
focusingSource = false;
|
|
44
46
|
/** Invalidates an in-flight asynchronous browser start when ownership ends. */
|
|
45
47
|
deckBrowserGeneration = 0;
|
|
46
48
|
claim;
|
|
47
|
-
reconciling = false;
|
|
48
49
|
suspended = false;
|
|
49
50
|
submittingDir;
|
|
50
51
|
stdinListener;
|
|
@@ -153,6 +154,10 @@ export class InboxController {
|
|
|
153
154
|
return;
|
|
154
155
|
}
|
|
155
156
|
if (this.screen === 'detail' && this.adapter !== undefined) {
|
|
157
|
+
if ((input === 'g' || input === 'G') && this.adapter.canAcceptHostKeys()) {
|
|
158
|
+
void this.focusSelectedSource();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
156
161
|
if ((input === 'w' || input === 'W') && this.adapter.canAcceptHostKeys()) {
|
|
157
162
|
void this.openDeckBrowser();
|
|
158
163
|
return;
|
|
@@ -176,6 +181,8 @@ export class InboxController {
|
|
|
176
181
|
this.select(this.selectedIndex + 1);
|
|
177
182
|
else if (input === 'k' || key.upArrow)
|
|
178
183
|
this.select(this.selectedIndex - 1);
|
|
184
|
+
else if (input === 'g' || input === 'G')
|
|
185
|
+
void this.focusSelectedSource();
|
|
179
186
|
else if (key.return || input === 'a')
|
|
180
187
|
this.activate();
|
|
181
188
|
this.repaint();
|
|
@@ -196,13 +203,20 @@ export class InboxController {
|
|
|
196
203
|
return;
|
|
197
204
|
}
|
|
198
205
|
this.claim = { dir: item.dir, token: claim.token };
|
|
199
|
-
const deck =
|
|
200
|
-
if (deck ===
|
|
206
|
+
const deck = this.readDeck(item.dir);
|
|
207
|
+
if (deck === undefined) {
|
|
201
208
|
releaseClaim(item.dir, claim.token);
|
|
202
209
|
this.claim = undefined;
|
|
203
210
|
this.invalidate();
|
|
204
211
|
return;
|
|
205
212
|
}
|
|
213
|
+
const root = ticketRoot(item.dir);
|
|
214
|
+
if (root !== null) {
|
|
215
|
+
// A newly acquired claim makes every older running generation stale
|
|
216
|
+
// before the panel can mint its own work. This only dispatches cleanup.
|
|
217
|
+
const retirement = reconcileVisualRequestsForTicket(root, item.dir, claim.token);
|
|
218
|
+
void retirement.delivery.finally(kickInboxMaintenance);
|
|
219
|
+
}
|
|
206
220
|
// Notifications use the same canonical deck panel as every other deck:
|
|
207
221
|
// opening is not acknowledgement; panel completion is.
|
|
208
222
|
this.activeDeck = deck;
|
|
@@ -214,7 +228,7 @@ export class InboxController {
|
|
|
214
228
|
cols: this.detailSize().cols,
|
|
215
229
|
rows: this.detailSize().rows,
|
|
216
230
|
onDirty: () => this.repaint(),
|
|
217
|
-
|
|
231
|
+
visualProvider: this.visualProviderFor(item.dir, deck, claim.token),
|
|
218
232
|
onEditorRequest: () => this.editActiveDeckInput(),
|
|
219
233
|
followUpAvailable: followUp.available,
|
|
220
234
|
onFollowUpRequest: followUp.onRequest,
|
|
@@ -264,7 +278,6 @@ export class InboxController {
|
|
|
264
278
|
else {
|
|
265
279
|
this.resumeAfterChild();
|
|
266
280
|
this.rescan();
|
|
267
|
-
this.reconcileRoots();
|
|
268
281
|
this.repaint(true);
|
|
269
282
|
}
|
|
270
283
|
}
|
|
@@ -282,7 +295,8 @@ export class InboxController {
|
|
|
282
295
|
this.activeDeck = deck;
|
|
283
296
|
const followUp = this.followUpHandlers(this.claim.dir, deck);
|
|
284
297
|
this.adapter.setFollowUpHandlers(followUp.available, followUp.onRequest, followUp.onCancel);
|
|
285
|
-
|
|
298
|
+
// Reload replaces the capability before it mints the next panel generation.
|
|
299
|
+
this.adapter.reload(deck, this.visualProviderFor(this.claim.dir, deck, this.claim.token));
|
|
286
300
|
if (followUp.available)
|
|
287
301
|
this.adapter.setFollowUpState(this.followUpViewState(this.claim.dir));
|
|
288
302
|
this.repaint();
|
|
@@ -418,7 +432,6 @@ export class InboxController {
|
|
|
418
432
|
if (this.claim?.dir === dir)
|
|
419
433
|
this.leaveDetail();
|
|
420
434
|
this.rescan();
|
|
421
|
-
this.reconcileRoots();
|
|
422
435
|
this.repaint();
|
|
423
436
|
}
|
|
424
437
|
/** Return browser authority to the terminal deck without changing its draft. */
|
|
@@ -473,10 +486,9 @@ export class InboxController {
|
|
|
473
486
|
setupTerminal();
|
|
474
487
|
this.running = true;
|
|
475
488
|
this.watchRoots();
|
|
476
|
-
// Close the crash window between an earlier result publication and its
|
|
477
|
-
// handler launch: dispatch every resolved-but-undelivered ticket now.
|
|
478
|
-
this.reconcileRoots();
|
|
479
489
|
this.repaint(true);
|
|
490
|
+
// Crash repair is durable but never part of a human keystroke.
|
|
491
|
+
kickInboxMaintenance();
|
|
480
492
|
const heartbeat = setInterval(() => {
|
|
481
493
|
if (this.claim !== undefined)
|
|
482
494
|
heartbeatClaim(this.claim.dir, this.claim.token);
|
|
@@ -525,7 +537,6 @@ export class InboxController {
|
|
|
525
537
|
this.submittingDir = undefined;
|
|
526
538
|
this.leaveDetail(false);
|
|
527
539
|
this.rescan();
|
|
528
|
-
this.reconcileRoots();
|
|
529
540
|
this.repaint();
|
|
530
541
|
}
|
|
531
542
|
/** The controller owns the terminal handoff and repaint around $EDITOR. */
|
|
@@ -567,26 +578,71 @@ export class InboxController {
|
|
|
567
578
|
resolvedRoots() {
|
|
568
579
|
return this.options.roots ?? listInboxRoots().filter((root) => root.available).map((root) => root.root);
|
|
569
580
|
}
|
|
570
|
-
/**
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
if (this.
|
|
574
|
-
return;
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
581
|
+
/** Automatic ticket capability is deliberately marker + current-root-handler only. */
|
|
582
|
+
visualProviderFor(dir, deck, claimToken) {
|
|
583
|
+
// An inline provider is intentional host injection, independent of ticket capability.
|
|
584
|
+
if (this.options.visualProvider !== undefined)
|
|
585
|
+
return this.options.visualProvider;
|
|
586
|
+
const root = ticketRoot(dir);
|
|
587
|
+
if (root === null || deck.source?.visual !== VISUAL_CAPABILITY || registeredInboxRoot(root)?.visualHandler === undefined)
|
|
588
|
+
return undefined;
|
|
589
|
+
return (request) => this.startTicketVisual(root, dir, claimToken, request);
|
|
590
|
+
}
|
|
591
|
+
startTicketVisual(root, dir, claimToken, request) {
|
|
592
|
+
let watcher;
|
|
593
|
+
let settled = false;
|
|
594
|
+
let settle;
|
|
595
|
+
const result = new Promise((resolve) => { settle = resolve; });
|
|
596
|
+
const finish = (outcome) => {
|
|
597
|
+
if (settled)
|
|
598
|
+
return;
|
|
599
|
+
settled = true;
|
|
600
|
+
watcher?.close();
|
|
601
|
+
watcher = undefined;
|
|
602
|
+
settle(outcome);
|
|
603
|
+
};
|
|
604
|
+
let started;
|
|
605
|
+
try {
|
|
606
|
+
started = startVisualRequest({ root, dir, claimToken, request });
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
finish({ status: 'error', error: error instanceof Error ? error.message : String(error) });
|
|
610
|
+
return { result, cancel: () => { } };
|
|
611
|
+
}
|
|
612
|
+
const reread = () => {
|
|
613
|
+
if (settled)
|
|
614
|
+
return;
|
|
578
615
|
try {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
616
|
+
const outcome = readVisualResult(root, dir, request.requestId);
|
|
617
|
+
if (outcome === null)
|
|
618
|
+
return;
|
|
619
|
+
finish(outcome.status === 'ready'
|
|
620
|
+
? { status: 'ready', markdown: outcome.markdown }
|
|
621
|
+
: { status: 'error', error: outcome.error });
|
|
585
622
|
}
|
|
586
|
-
|
|
587
|
-
|
|
623
|
+
catch (error) {
|
|
624
|
+
finish({ status: 'error', error: error instanceof Error ? error.message : String(error) });
|
|
588
625
|
}
|
|
589
|
-
}
|
|
626
|
+
};
|
|
627
|
+
try {
|
|
628
|
+
// Install the watch before the first durable reread so a publication in
|
|
629
|
+
// the narrow setup window is either observed or found by that reread.
|
|
630
|
+
watcher = watch(join(visualsDir(dir), request.requestId), () => reread());
|
|
631
|
+
watcher.once('error', (error) => finish({ status: 'error', error: error.message }));
|
|
632
|
+
reread();
|
|
633
|
+
}
|
|
634
|
+
catch (error) {
|
|
635
|
+
finish({ status: 'error', error: error instanceof Error ? error.message : String(error) });
|
|
636
|
+
}
|
|
637
|
+
void started.delivery.then(reread, (error) => finish({ status: 'error', error: error instanceof Error ? error.message : String(error) }));
|
|
638
|
+
return {
|
|
639
|
+
result,
|
|
640
|
+
cancel: () => {
|
|
641
|
+
watcher?.close();
|
|
642
|
+
watcher = undefined;
|
|
643
|
+
void cancelVisualRequest(root, dir, request.requestId).finally(kickInboxMaintenance);
|
|
644
|
+
},
|
|
645
|
+
};
|
|
590
646
|
}
|
|
591
647
|
leaveDetail(release = true) {
|
|
592
648
|
this.deckBrowserGeneration++;
|
|
@@ -629,11 +685,42 @@ export class InboxController {
|
|
|
629
685
|
const selected = this.items[this.selectedIndex];
|
|
630
686
|
if (selected === undefined)
|
|
631
687
|
return this.previewViewport(this.passiveDetailLines(width), width, rows);
|
|
688
|
+
const focusHint = this.focusAvailable(selected.dir) ? ` ${DIM}g${RESET} chat` : '';
|
|
632
689
|
const footer = selected.kind === 'deck'
|
|
633
|
-
? [` ${DIM}Enter${RESET} opens the full ticket ${DIM}u/d${RESET} scroll ${DIM}j/k${RESET} select`, ` ${DIM}Active ask:${RESET} c comment u/d scroll w browser ${DIM}q${RESET} close`]
|
|
634
|
-
: [` ${DIM}Enter${RESET} opens the full review ${DIM}u/d${RESET} scroll ${DIM}j/k${RESET} select`, ` ${DIM}q${RESET} close`];
|
|
690
|
+
? [` ${DIM}Enter${RESET} opens the full ticket ${DIM}u/d${RESET} scroll ${DIM}j/k${RESET} select`, ` ${DIM}Active ask:${RESET} c comment u/d scroll w browser${focusHint} ${DIM}q${RESET} close`]
|
|
691
|
+
: [` ${DIM}Enter${RESET} opens the full review ${DIM}u/d${RESET} scroll ${DIM}j/k${RESET} select`, ` ${focusHint} ${DIM}q${RESET} close`];
|
|
635
692
|
return [...this.previewViewport(this.passiveDetailLines(width), width, Math.max(0, rows - footer.length)), ...footer.map((line) => clipLine(line, width))];
|
|
636
693
|
}
|
|
694
|
+
focusAvailable(dir) {
|
|
695
|
+
const root = ticketRoot(dir);
|
|
696
|
+
return this.options.targetPane !== undefined && root !== null && registeredInboxRoot(root)?.focusHandler !== undefined;
|
|
697
|
+
}
|
|
698
|
+
/** Ask the registered host to reveal this ticket's source beside the pane
|
|
699
|
+
* underneath the popup. The popup closes only after the host acknowledges
|
|
700
|
+
* that focus succeeded, so a failure remains visible as status. */
|
|
701
|
+
async focusSelectedSource() {
|
|
702
|
+
const item = this.items[this.selectedIndex];
|
|
703
|
+
const targetPane = this.options.targetPane;
|
|
704
|
+
if (item === undefined || targetPane === undefined || this.focusingSource)
|
|
705
|
+
return;
|
|
706
|
+
const root = ticketRoot(item.dir);
|
|
707
|
+
const registration = root === null ? null : registeredInboxRoot(root);
|
|
708
|
+
if (root === null || registration?.focusHandler === undefined)
|
|
709
|
+
return;
|
|
710
|
+
const event = { schema: 'humanloop.focus/v1', root, dir: item.dir, ticketId: item.id, targetPane };
|
|
711
|
+
this.focusingSource = true;
|
|
712
|
+
try {
|
|
713
|
+
await runHandler(registration.focusHandler.command, registration.focusHandler.args, event);
|
|
714
|
+
this.close();
|
|
715
|
+
}
|
|
716
|
+
catch (error) {
|
|
717
|
+
this.status = error instanceof Error ? error.message : String(error);
|
|
718
|
+
this.repaint();
|
|
719
|
+
}
|
|
720
|
+
finally {
|
|
721
|
+
this.focusingSource = false;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
637
724
|
passiveDetailLines(width) {
|
|
638
725
|
const selected = this.items[this.selectedIndex];
|
|
639
726
|
if (selected === undefined)
|
|
@@ -727,15 +814,21 @@ export class InboxController {
|
|
|
727
814
|
this.frame = diff.nextPrevFrame;
|
|
728
815
|
}
|
|
729
816
|
watchRoots() {
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
817
|
+
// A single state-level activity marker replaces one watcher per historical
|
|
818
|
+
// root. Closing hundreds of fs watchers was itself a multi-second UI path.
|
|
819
|
+
const activity = basename(inboxActivityPath());
|
|
820
|
+
try {
|
|
821
|
+
this.watchers.push(watch(inboxStateDirectory(), (_event, file) => {
|
|
822
|
+
if (String(file) === activity) {
|
|
823
|
+
this.invalidate();
|
|
824
|
+
kickInboxMaintenance();
|
|
825
|
+
}
|
|
826
|
+
}));
|
|
735
827
|
}
|
|
828
|
+
catch { /* the state directory appears when the next ticket is submitted */ }
|
|
736
829
|
if (this.options.roots === undefined) {
|
|
737
830
|
try {
|
|
738
|
-
this.watchers.push(watch(inboxRootsDirectory(), () => this.invalidate()));
|
|
831
|
+
this.watchers.push(watch(inboxRootsDirectory(), () => { this.invalidate(); kickInboxMaintenance(); }));
|
|
739
832
|
}
|
|
740
833
|
catch { /* registry appears after the next explicit open */ }
|
|
741
834
|
}
|
|
@@ -9,8 +9,6 @@ export declare function deliveryErrorPath(dir: string): string;
|
|
|
9
9
|
export declare function followupRequestPath(dir: string): string;
|
|
10
10
|
export declare function followupResultPath(dir: string): string;
|
|
11
11
|
export declare function visualsDir(dir: string): string;
|
|
12
|
-
export declare function visualMdPath(dir: string, id: string): string;
|
|
13
|
-
export declare function visualAnsiPath(dir: string, id: string): string;
|
|
14
12
|
/** Spawns a handler `{command,args}` with `event` as JSON on stdin. Exit 0
|
|
15
13
|
* acknowledges; a nonzero exit, spawn error, or 30s timeout rejects with an
|
|
16
14
|
* Error carrying the handler's captured stderr. Shared by completion delivery
|
|
@@ -22,6 +20,8 @@ export declare function isResolved(dir: string): boolean;
|
|
|
22
20
|
export declare function isClaimed(dir: string): boolean;
|
|
23
21
|
export declare function stampCanvasNode(deck: Deck): void;
|
|
24
22
|
export declare function atomicWriteJson(path: string, value: unknown): void;
|
|
23
|
+
/** Publish one immutable JSON record without replacing an existing winner. */
|
|
24
|
+
export declare function publishJsonExclusive(path: string, value: unknown): boolean;
|
|
25
25
|
export declare function readJson<T>(path: string): T | null;
|
|
26
26
|
/** Runs a short filesystem transition under a token-checked, crash-reclaimable directory lock. */
|
|
27
27
|
export declare function withExclusiveDirectoryLock<T>(path: string, operation: () => T, options?: {
|
package/dist/inbox/convention.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, linkSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
@@ -13,8 +13,6 @@ export function deliveryErrorPath(dir) { return `${dir}/delivery-error.json`; }
|
|
|
13
13
|
export function followupRequestPath(dir) { return `${dir}/followup-request.json`; }
|
|
14
14
|
export function followupResultPath(dir) { return `${dir}/followup-result.json`; }
|
|
15
15
|
export function visualsDir(dir) { return `${dir}/visuals`; }
|
|
16
|
-
export function visualMdPath(dir, id) { return `${dir}/visuals/${id}.md`; }
|
|
17
|
-
export function visualAnsiPath(dir, id) { return `${dir}/visuals/${id}.ansi`; }
|
|
18
16
|
/** Spawns a handler `{command,args}` with `event` as JSON on stdin. Exit 0
|
|
19
17
|
* acknowledges; a nonzero exit, spawn error, or 30s timeout rejects with an
|
|
20
18
|
* Error carrying the handler's captured stderr. Shared by completion delivery
|
|
@@ -65,6 +63,24 @@ export function atomicWriteJson(path, value) {
|
|
|
65
63
|
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
66
64
|
renameSync(tmp, path);
|
|
67
65
|
}
|
|
66
|
+
/** Publish one immutable JSON record without replacing an existing winner. */
|
|
67
|
+
export function publishJsonExclusive(path, value) {
|
|
68
|
+
const tmp = `${path}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
69
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
70
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
71
|
+
try {
|
|
72
|
+
linkSync(tmp, path);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error.code === 'EEXIST')
|
|
77
|
+
return false;
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
unlinkSync(tmp);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
68
84
|
export function readJson(path) {
|
|
69
85
|
try {
|
|
70
86
|
return JSON.parse(readFileSync(path, 'utf8'));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Deck, FollowUpState,
|
|
1
|
+
import type { Deck, FollowUpState, InteractionResponse, VisualProvider } from '../types.js';
|
|
2
2
|
import type { Key } from '../tui/terminal.js';
|
|
3
3
|
export interface DeckAdapterOptions {
|
|
4
4
|
dir: string;
|
|
@@ -9,7 +9,7 @@ export interface DeckAdapterOptions {
|
|
|
9
9
|
onComplete: (responses: InteractionResponse[]) => void;
|
|
10
10
|
onBack: () => void;
|
|
11
11
|
onDirty: () => void;
|
|
12
|
-
|
|
12
|
+
visualProvider?: VisualProvider;
|
|
13
13
|
onEditorRequest?: () => void;
|
|
14
14
|
followUpAvailable?: boolean;
|
|
15
15
|
onFollowUpRequest?: (question: string) => void;
|
|
@@ -30,6 +30,6 @@ export declare class DeckAdapter {
|
|
|
30
30
|
canAcceptHostKeys(): boolean;
|
|
31
31
|
handleKey(input: string, key: Key): void;
|
|
32
32
|
/** Fresh descriptor reads preserve mounted answers for interaction ids still present. */
|
|
33
|
-
reload(deck: Deck): void;
|
|
33
|
+
reload(deck: Deck, visualProvider: VisualProvider | undefined): void;
|
|
34
34
|
close(): void;
|
|
35
35
|
}
|
|
@@ -20,7 +20,7 @@ export class DeckAdapter {
|
|
|
20
20
|
onExit: () => { if (notificationsAcknowledged(opts.deck, this.responses))
|
|
21
21
|
opts.onComplete(this.responses); },
|
|
22
22
|
onDirty: opts.onDirty,
|
|
23
|
-
|
|
23
|
+
visualProvider: opts.visualProvider,
|
|
24
24
|
onEditorRequest: opts.onEditorRequest,
|
|
25
25
|
followUpAvailable: opts.followUpAvailable,
|
|
26
26
|
onFollowUpRequest: opts.onFollowUpRequest,
|
|
@@ -42,8 +42,8 @@ export class DeckAdapter {
|
|
|
42
42
|
this.panel.handleKey(input, key);
|
|
43
43
|
}
|
|
44
44
|
/** Fresh descriptor reads preserve mounted answers for interaction ids still present. */
|
|
45
|
-
reload(deck) {
|
|
46
|
-
this.panel.loadDeck(deck, { progressPath: progressPath(this.opts.dir) });
|
|
45
|
+
reload(deck, visualProvider) {
|
|
46
|
+
this.panel.loadDeck(deck, { progressPath: progressPath(this.opts.dir), visualProvider });
|
|
47
47
|
this.opts.onDirty();
|
|
48
48
|
}
|
|
49
49
|
close() { this.panel.unmount(); }
|
|
@@ -18,7 +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
|
-
|
|
21
|
+
visual: z.ZodOptional<z.ZodLiteral<"humanloop.visual/v1">>;
|
|
22
22
|
}, z.core.$strip>>;
|
|
23
23
|
interactions: z.ZodArray<z.ZodObject<{
|
|
24
24
|
id: z.ZodString;
|
|
@@ -59,7 +59,7 @@ export declare const reviewDescriptorSchema: z.ZodObject<{
|
|
|
59
59
|
askedBy: z.ZodOptional<z.ZodString>;
|
|
60
60
|
blockedSince: z.ZodOptional<z.ZodString>;
|
|
61
61
|
nodeId: z.ZodOptional<z.ZodString>;
|
|
62
|
-
|
|
62
|
+
visual: z.ZodOptional<z.ZodLiteral<"humanloop.visual/v1">>;
|
|
63
63
|
}, z.core.$strip>;
|
|
64
64
|
blockedSince: z.ZodString;
|
|
65
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, followupRequestPath, followupResultPath, progressPath, responsePath, reviewPath } from './convention.js';
|
|
3
|
+
import { claimPath, deckPath, deliveryErrorPath, deliveryPath, followupRequestPath, followupResultPath, progressPath, responsePath, reviewPath, visualsDir } from './convention.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { INTERACTION_KINDS } from '../types.js';
|
|
6
6
|
import { checkMarkdown } from '../render/termrender.js';
|
|
@@ -11,7 +11,7 @@ const interactionSchema = z.object({
|
|
|
11
11
|
title: z.string().min(1), subtitle: z.string().min(1).optional(), body: z.string().optional(), bodyPath: z.string().optional(),
|
|
12
12
|
options: z.array(interactionOptionSchema), multiSelect: z.boolean().optional(), allowFreetext: z.boolean().optional(), freetextLabel: z.string().optional(), kind: z.enum(INTERACTION_KINDS).optional(), preAnswered: preAnswerSchema.optional(),
|
|
13
13
|
});
|
|
14
|
-
const deckSourceSchema = z.object({ sessionName: z.string().optional(), askedBy: z.string().optional(), blockedSince: z.string().optional(), nodeId: z.string().optional(),
|
|
14
|
+
const deckSourceSchema = z.object({ sessionName: z.string().optional(), askedBy: z.string().optional(), blockedSince: z.string().optional(), nodeId: z.string().optional(), visual: z.literal('humanloop.visual/v1').optional() });
|
|
15
15
|
export const deckSchema = z.object({ title: z.string().optional(), source: deckSourceSchema.optional(), interactions: z.array(interactionSchema).min(1) }).superRefine((input, ctx) => {
|
|
16
16
|
const seen = new Set();
|
|
17
17
|
input.interactions.forEach((interaction, index) => {
|
|
@@ -77,8 +77,8 @@ export function validateReviewProjection(dir, parsed) {
|
|
|
77
77
|
throw new Error('review file must be an existing absolute markdown file');
|
|
78
78
|
const file = realpathSync(descriptor.file);
|
|
79
79
|
const output = resolve(realpathSync(dirname(descriptor.output)), basename(descriptor.output));
|
|
80
|
-
const reserved = new Set(['deck.json', 'review.json', 'response.json', 'progress.json', 'claim.json', 'delivery.json', 'delivery-error.json', '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)]);
|
|
80
|
+
const reserved = new Set(['deck.json', 'review.json', 'response.json', 'progress.json', 'claim.json', 'delivery.json', 'delivery-error.json', 'followup-request.json', 'followup-result.json', 'visuals']);
|
|
81
|
+
const ownProtocolPaths = new Set([deckPath(dir), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir), deliveryPath(dir), deliveryErrorPath(dir), followupRequestPath(dir), followupResultPath(dir), visualsDir(dir)]);
|
|
82
82
|
if (output === file || reserved.has(basename(output)) || ownProtocolPaths.has(output))
|
|
83
83
|
throw new Error('review output must not alias the source or ticket protocol files');
|
|
84
84
|
return { ...descriptor, file, output };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Start one detached repair pass. The lease makes repeated UI gestures free. */
|
|
2
|
+
export declare function kickInboxMaintenance(): void;
|
|
3
|
+
/** Run repair and stay alive only until the durable cleanup retry queue is empty. */
|
|
4
|
+
export declare function runInboxMaintenance(lease: string): Promise<void>;
|