@crouton-kit/humanloop 0.3.38 → 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 +26 -2
- package/dist/inbox/controller.js +265 -34
- 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/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 +40 -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/completion.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { readdirSync, statSync, unlinkSync } from 'node:fs';
|
|
2
|
-
import { spawn } from 'node:child_process';
|
|
3
2
|
import { basename, resolve } from 'node:path';
|
|
4
|
-
import { atomicWriteJson, deliveryErrorPath, deliveryPath, responsePath, readJson, reviewPath, withExclusiveDirectoryLockAsync } from './convention.js';
|
|
3
|
+
import { atomicWriteJson, deliveryErrorPath, deliveryPath, responsePath, readJson, reviewPath, runHandler, withExclusiveDirectoryLockAsync } from './convention.js';
|
|
5
4
|
import { registeredInboxRoot } from './registry.js';
|
|
6
5
|
import { readTicketResult, ticketRoot } from './tickets.js';
|
|
7
6
|
import { validateReviewProjection } from './deck-schema.js';
|
|
@@ -12,31 +11,6 @@ function receiptMatches(dir) {
|
|
|
12
11
|
function eventFor(root, dir, result) {
|
|
13
12
|
return { schema: 'humanloop.completion/v1', root, dir, ticketId: basename(dir), kind: result.kind, outcome: result.kind === 'canceled' ? 'canceled' : 'resolved', responsePath: responsePath(dir) };
|
|
14
13
|
}
|
|
15
|
-
async function runHandler(command, args, event) {
|
|
16
|
-
await new Promise((resolvePromise, rejectPromise) => {
|
|
17
|
-
// stdout stays ignored (a handler acknowledges by exit code, never stdout),
|
|
18
|
-
// but stderr is captured so a nonzero exit surfaces the handler's own
|
|
19
|
-
// diagnostics in the delivery-error record instead of a bare exit code.
|
|
20
|
-
const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
21
|
-
let stderr = '';
|
|
22
|
-
child.stderr?.on('data', (chunk) => { stderr += chunk; if (stderr.length > 8192)
|
|
23
|
-
stderr = stderr.slice(-8192); });
|
|
24
|
-
let timedOut = false;
|
|
25
|
-
const timeout = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 30_000);
|
|
26
|
-
child.once('error', (error) => { clearTimeout(timeout); rejectPromise(error); });
|
|
27
|
-
child.once('exit', (code, signal) => {
|
|
28
|
-
clearTimeout(timeout);
|
|
29
|
-
const detail = stderr.trim() === '' ? '' : `: ${stderr.trim()}`;
|
|
30
|
-
if (timedOut)
|
|
31
|
-
rejectPromise(new Error(`completion handler timed out after 30 seconds${detail}`));
|
|
32
|
-
else if (code === 0)
|
|
33
|
-
resolvePromise();
|
|
34
|
-
else
|
|
35
|
-
rejectPromise(new Error(`completion handler failed (${signal ?? code ?? 'unknown'})${detail}`));
|
|
36
|
-
});
|
|
37
|
-
child.stdin.end(`${JSON.stringify(event)}\n`);
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
14
|
function projectReview(dir, result) {
|
|
41
15
|
if (result.kind !== 'review')
|
|
42
16
|
return;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { InteractionResponse, TicketSummary } 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';
|
|
4
5
|
export interface InboxControllerOptions {
|
|
5
6
|
roots?: string[];
|
|
6
7
|
cols?: number;
|
|
@@ -9,6 +10,8 @@ export interface InboxControllerOptions {
|
|
|
9
10
|
completeDeck?: (dir: string, responses: InteractionResponse[], token: string) => Promise<unknown>;
|
|
10
11
|
startDeckBrowser?: typeof startWebServer;
|
|
11
12
|
openBrowser?: (url: string) => void;
|
|
13
|
+
/** Test seam; production uses the standard conversation-backed visual generator. */
|
|
14
|
+
visualGeneratorForSession?: typeof visualGeneratorForConversationSession;
|
|
12
15
|
}
|
|
13
16
|
type Screen = 'list' | 'detail';
|
|
14
17
|
/** One terminal owner for scanning, stable selection, claims, and frame diffs. */
|
|
@@ -19,11 +22,21 @@ export declare class InboxController {
|
|
|
19
22
|
private items;
|
|
20
23
|
private selectedDir;
|
|
21
24
|
private selectedIndex;
|
|
25
|
+
/** Scroll state belongs to the passive preview, never to an editable deck. */
|
|
26
|
+
private previewScrollOffset;
|
|
22
27
|
private screen;
|
|
23
28
|
private adapter;
|
|
29
|
+
private activeDeck;
|
|
24
30
|
private reviewAdapter;
|
|
25
31
|
private deckBrowser;
|
|
32
|
+
/** Blocks terminal input until browser finalization/shutdown is quiescent. */
|
|
33
|
+
private deckBrowserTakingBack;
|
|
34
|
+
/** The single accepted submit that crossed the browser boundary. */
|
|
35
|
+
private deckBrowserFinalizing;
|
|
26
36
|
private deckBrowserStarting;
|
|
37
|
+
private deckBrowserStartingGeneration;
|
|
38
|
+
/** Invalidates an in-flight asynchronous browser start when ownership ends. */
|
|
39
|
+
private deckBrowserGeneration;
|
|
27
40
|
private claim;
|
|
28
41
|
private reconciling;
|
|
29
42
|
private suspended;
|
|
@@ -56,16 +69,24 @@ export declare class InboxController {
|
|
|
56
69
|
* in ReviewAdapter; the controller only owns the terminal handoff. */
|
|
57
70
|
private activateReview;
|
|
58
71
|
reloadSelectedDeck(): void;
|
|
72
|
+
private followUpHandlers;
|
|
73
|
+
private readDeck;
|
|
74
|
+
private followUpViewState;
|
|
75
|
+
private refreshFollowUp;
|
|
59
76
|
/** Hand the selected deck to its browser surface while retaining this ticket's claim. */
|
|
60
77
|
private openDeckBrowser;
|
|
61
|
-
/**
|
|
78
|
+
/** Finalize through this controller's claim-safe lifecycle before HTTP acks. */
|
|
79
|
+
private finalizeDeckBrowser;
|
|
80
|
+
/** The browser has finalized through the controller; reconcile its owner delivery. */
|
|
62
81
|
private finishDeckBrowser;
|
|
63
82
|
/** Return browser authority to the terminal deck without changing its draft. */
|
|
64
83
|
private takeBackDeckBrowser;
|
|
65
84
|
close(): void;
|
|
66
85
|
run(): Promise<void>;
|
|
67
86
|
private complete;
|
|
68
|
-
/**
|
|
87
|
+
/** The controller owns the terminal handoff and repaint around $EDITOR. */
|
|
88
|
+
private editActiveDeckInput;
|
|
89
|
+
/** Give the raw TTY to a child process (native review editor or $EDITOR). */
|
|
69
90
|
private suspendForChild;
|
|
70
91
|
/** Retake the TTY after the child exits and force a full repaint. */
|
|
71
92
|
private resumeAfterChild;
|
|
@@ -75,8 +96,11 @@ export declare class InboxController {
|
|
|
75
96
|
private reconcileRoots;
|
|
76
97
|
private leaveDetail;
|
|
77
98
|
private select;
|
|
99
|
+
private scrollPreview;
|
|
78
100
|
private detailSize;
|
|
79
101
|
private detailLines;
|
|
102
|
+
private passiveDetailLines;
|
|
103
|
+
private previewViewport;
|
|
80
104
|
private withStatus;
|
|
81
105
|
private repaint;
|
|
82
106
|
private watchRoots;
|
package/dist/inbox/controller.js
CHANGED
|
@@ -9,13 +9,17 @@ import { BOLD, CYAN, DIM, GRAY, RESET, YELLOW, clipLine } from '../tui/ansi.js';
|
|
|
9
9
|
import { buildInboxLines } from './tui.js';
|
|
10
10
|
import { inboxLayout } from './layout.js';
|
|
11
11
|
import { scanInbox } from './scan.js';
|
|
12
|
-
import { inboxRootsDirectory, listInboxRoots } from './registry.js';
|
|
12
|
+
import { inboxRootsDirectory, listInboxRoots, registeredInboxRoot } from './registry.js';
|
|
13
13
|
import { claimTicket, heartbeatClaim, releaseClaim } from './claim.js';
|
|
14
|
-
import { completeDeck, readTicketResult } from './tickets.js';
|
|
14
|
+
import { completeDeck, readTicketResult, ticketRoot } from './tickets.js';
|
|
15
15
|
import { reconcileCompletions } from './completion.js';
|
|
16
|
-
import { clearProgress, deckPath, progressPath, readJson, reviewPath } from './convention.js';
|
|
16
|
+
import { clearProgress, deckPath, progressPath, readJson, responsePath, reviewPath } from './convention.js';
|
|
17
17
|
import { DeckAdapter } from './deck-adapter.js';
|
|
18
|
+
import { validateDeck } from './deck-schema.js';
|
|
18
19
|
import { ReviewAdapter } from './review-adapter.js';
|
|
20
|
+
import { visualGeneratorForConversationSession } from '../visuals/conversation.js';
|
|
21
|
+
import { editBufferInEditor } from '../editor/roundtrip.js';
|
|
22
|
+
import { cancelFollowUp, readFollowUp, requestFollowUp } from './followup.js';
|
|
19
23
|
/** One terminal owner for scanning, stable selection, claims, and frame diffs. */
|
|
20
24
|
export class InboxController {
|
|
21
25
|
options;
|
|
@@ -24,11 +28,21 @@ export class InboxController {
|
|
|
24
28
|
items = [];
|
|
25
29
|
selectedDir;
|
|
26
30
|
selectedIndex = 0;
|
|
31
|
+
/** Scroll state belongs to the passive preview, never to an editable deck. */
|
|
32
|
+
previewScrollOffset = 0;
|
|
27
33
|
screen = 'list';
|
|
28
34
|
adapter;
|
|
35
|
+
activeDeck;
|
|
29
36
|
reviewAdapter;
|
|
30
37
|
deckBrowser;
|
|
38
|
+
/** Blocks terminal input until browser finalization/shutdown is quiescent. */
|
|
39
|
+
deckBrowserTakingBack = false;
|
|
40
|
+
/** The single accepted submit that crossed the browser boundary. */
|
|
41
|
+
deckBrowserFinalizing;
|
|
31
42
|
deckBrowserStarting = false;
|
|
43
|
+
deckBrowserStartingGeneration;
|
|
44
|
+
/** Invalidates an in-flight asynchronous browser start when ownership ends. */
|
|
45
|
+
deckBrowserGeneration = 0;
|
|
32
46
|
claim;
|
|
33
47
|
reconciling = false;
|
|
34
48
|
suspended = false;
|
|
@@ -79,10 +93,12 @@ export class InboxController {
|
|
|
79
93
|
if (this.items.length === 0) {
|
|
80
94
|
this.selectedIndex = 0;
|
|
81
95
|
this.selectedDir = undefined;
|
|
96
|
+
this.previewScrollOffset = 0;
|
|
82
97
|
return;
|
|
83
98
|
}
|
|
84
99
|
this.selectedIndex = Math.min(priorIndex, this.items.length - 1);
|
|
85
100
|
this.selectedDir = this.items[this.selectedIndex].dir;
|
|
101
|
+
this.previewScrollOffset = 0;
|
|
86
102
|
}
|
|
87
103
|
invalidate() { this.rescan(); this.repaint(); }
|
|
88
104
|
resize(cols = getTerminalSize().cols, rows = getTerminalSize().rows) {
|
|
@@ -96,7 +112,7 @@ export class InboxController {
|
|
|
96
112
|
const geometry = inboxLayout(this.cols, this.rows, this.screen);
|
|
97
113
|
if (geometry.mode === 'minimum')
|
|
98
114
|
return [`${YELLOW}Resize terminal to at least 60×18 to use inbox.${RESET}`];
|
|
99
|
-
const list = buildInboxLines(this.items, geometry.listWidth, this.selectedIndex);
|
|
115
|
+
const list = buildInboxLines(this.items, geometry.listWidth, this.selectedIndex, geometry.height);
|
|
100
116
|
const detail = this.detailLines(geometry.detailWidth, geometry.height);
|
|
101
117
|
if (geometry.mode === 'list')
|
|
102
118
|
return this.withStatus(list);
|
|
@@ -126,6 +142,11 @@ export class InboxController {
|
|
|
126
142
|
this.close();
|
|
127
143
|
return;
|
|
128
144
|
}
|
|
145
|
+
// Take-back is an ownership boundary, not an instantaneous UI toggle:
|
|
146
|
+
// discard every key until a pre-existing browser finalizer and the listener
|
|
147
|
+
// have both settled, so terminal edits cannot race browser completion.
|
|
148
|
+
if (this.deckBrowserTakingBack)
|
|
149
|
+
return;
|
|
129
150
|
if (this.deckBrowser !== undefined) {
|
|
130
151
|
if (input === 'w' || input === 'W')
|
|
131
152
|
void this.takeBackDeckBrowser();
|
|
@@ -144,7 +165,14 @@ export class InboxController {
|
|
|
144
165
|
this.close();
|
|
145
166
|
return;
|
|
146
167
|
}
|
|
147
|
-
|
|
168
|
+
// Passive previews share the deck's documented scroll bindings without
|
|
169
|
+
// claiming or mounting an editable panel. Ctrl+E/Y are line-wise aliases;
|
|
170
|
+
// u/d and Ctrl+U/D/Page keys move a useful chunk.
|
|
171
|
+
if (input === 'd' || key.pageDown || (key.ctrl && (input === 'd' || input === 'e')))
|
|
172
|
+
this.scrollPreview(input === 'e' ? 1 : 10);
|
|
173
|
+
else if (input === 'u' || key.pageUp || (key.ctrl && (input === 'u' || input === 'y')))
|
|
174
|
+
this.scrollPreview(input === 'y' ? -1 : -10);
|
|
175
|
+
else if (input === 'j' || key.downArrow)
|
|
148
176
|
this.select(this.selectedIndex + 1);
|
|
149
177
|
else if (input === 'k' || key.upArrow)
|
|
150
178
|
this.select(this.selectedIndex - 1);
|
|
@@ -175,10 +203,10 @@ export class InboxController {
|
|
|
175
203
|
this.invalidate();
|
|
176
204
|
return;
|
|
177
205
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
206
|
+
// Notifications use the same canonical deck panel as every other deck:
|
|
207
|
+
// opening is not acknowledgement; panel completion is.
|
|
208
|
+
this.activeDeck = deck;
|
|
209
|
+
const followUp = this.followUpHandlers(item.dir, deck);
|
|
182
210
|
this.screen = 'detail';
|
|
183
211
|
this.adapter = new DeckAdapter({
|
|
184
212
|
dir: item.dir,
|
|
@@ -186,9 +214,16 @@ export class InboxController {
|
|
|
186
214
|
cols: this.detailSize().cols,
|
|
187
215
|
rows: this.detailSize().rows,
|
|
188
216
|
onDirty: () => this.repaint(),
|
|
217
|
+
generateVisual: deck.source?.originatingConversationSessionId === undefined ? undefined : (this.options.visualGeneratorForSession ?? visualGeneratorForConversationSession)(deck.source.originatingConversationSessionId),
|
|
218
|
+
onEditorRequest: () => this.editActiveDeckInput(),
|
|
219
|
+
followUpAvailable: followUp.available,
|
|
220
|
+
onFollowUpRequest: followUp.onRequest,
|
|
221
|
+
onFollowUpCancel: followUp.onCancel,
|
|
189
222
|
onBack: () => { this.leaveDetail(); this.repaint(); },
|
|
190
223
|
onComplete: (responses) => { void this.complete(responses); },
|
|
191
224
|
});
|
|
225
|
+
if (followUp.available)
|
|
226
|
+
this.adapter.setFollowUpState(this.followUpViewState(item.dir));
|
|
192
227
|
this.watchSelected(item.dir);
|
|
193
228
|
}
|
|
194
229
|
/** Claim the review, hand the whole popup TTY to the native editor, then
|
|
@@ -234,7 +269,72 @@ export class InboxController {
|
|
|
234
269
|
}
|
|
235
270
|
}
|
|
236
271
|
}
|
|
237
|
-
reloadSelectedDeck() {
|
|
272
|
+
reloadSelectedDeck() {
|
|
273
|
+
if (this.adapter === undefined || this.claim === undefined) {
|
|
274
|
+
this.repaint();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const deck = this.readDeck(this.claim.dir);
|
|
278
|
+
if (deck === undefined) {
|
|
279
|
+
this.repaint();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
this.activeDeck = deck;
|
|
283
|
+
const followUp = this.followUpHandlers(this.claim.dir, deck);
|
|
284
|
+
this.adapter.setFollowUpHandlers(followUp.available, followUp.onRequest, followUp.onCancel);
|
|
285
|
+
this.adapter.reload(deck);
|
|
286
|
+
if (followUp.available)
|
|
287
|
+
this.adapter.setFollowUpState(this.followUpViewState(this.claim.dir));
|
|
288
|
+
this.repaint();
|
|
289
|
+
}
|
|
290
|
+
followUpHandlers(dir, deck) {
|
|
291
|
+
const root = ticketRoot(dir);
|
|
292
|
+
const available = root !== null && registeredInboxRoot(root)?.followUpHandler !== undefined && isAnswerBearingDeck(deck);
|
|
293
|
+
if (!available)
|
|
294
|
+
return { available: false };
|
|
295
|
+
return {
|
|
296
|
+
available: true,
|
|
297
|
+
onRequest: (question) => {
|
|
298
|
+
requestFollowUp(root, dir, { question });
|
|
299
|
+
this.refreshFollowUp(dir);
|
|
300
|
+
},
|
|
301
|
+
onCancel: () => {
|
|
302
|
+
cancelFollowUp(root, dir);
|
|
303
|
+
this.refreshFollowUp(dir);
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
readDeck(dir) {
|
|
308
|
+
const deck = readJson(deckPath(dir));
|
|
309
|
+
if (deck === null)
|
|
310
|
+
return undefined;
|
|
311
|
+
try {
|
|
312
|
+
return validateDeck(deck);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
followUpViewState(dir) {
|
|
319
|
+
const { request, result } = readFollowUp(dir);
|
|
320
|
+
if (request === null)
|
|
321
|
+
return { status: 'idle' };
|
|
322
|
+
if (result !== null && result.requestId === request.requestId) {
|
|
323
|
+
return result.status === 'ready'
|
|
324
|
+
? { status: 'ready', markdown: result.markdown }
|
|
325
|
+
: { status: 'error', error: result.error };
|
|
326
|
+
}
|
|
327
|
+
return request.state === 'running' ? { status: 'running' } : { status: 'idle' };
|
|
328
|
+
}
|
|
329
|
+
refreshFollowUp(dir) {
|
|
330
|
+
if (this.adapter === undefined || this.claim?.dir !== dir || this.activeDeck === undefined)
|
|
331
|
+
return;
|
|
332
|
+
const followUp = this.followUpHandlers(dir, this.activeDeck);
|
|
333
|
+
this.adapter.setFollowUpHandlers(followUp.available, followUp.onRequest, followUp.onCancel);
|
|
334
|
+
if (followUp.available)
|
|
335
|
+
this.adapter.setFollowUpState(this.followUpViewState(dir));
|
|
336
|
+
this.repaint();
|
|
337
|
+
}
|
|
238
338
|
/** Hand the selected deck to its browser surface while retaining this ticket's claim. */
|
|
239
339
|
async openDeckBrowser() {
|
|
240
340
|
if (this.deckBrowser !== undefined || this.deckBrowserStarting || this.adapter === undefined || this.claim === undefined)
|
|
@@ -246,26 +346,71 @@ export class InboxController {
|
|
|
246
346
|
if (deck === null)
|
|
247
347
|
return;
|
|
248
348
|
this.deckBrowserStarting = true;
|
|
349
|
+
const generation = ++this.deckBrowserGeneration;
|
|
350
|
+
this.deckBrowserStartingGeneration = generation;
|
|
351
|
+
const claim = this.claim;
|
|
352
|
+
const adapter = this.adapter;
|
|
249
353
|
try {
|
|
250
354
|
const start = this.options.startDeckBrowser ?? startWebServer;
|
|
251
355
|
let browser;
|
|
252
356
|
browser = await start({
|
|
253
357
|
dir: item.dir,
|
|
254
358
|
deck,
|
|
255
|
-
|
|
359
|
+
finalize: async (responses) => this.finalizeDeckBrowser(item.dir, claim.token, generation, responses),
|
|
360
|
+
onSubmit: () => this.finishDeckBrowser(item.dir, browser),
|
|
256
361
|
});
|
|
362
|
+
// Closing, taking back, or losing the claim while listen() was pending
|
|
363
|
+
// retires this start. Stop the fresh listener before it can open a tab.
|
|
364
|
+
if (this.closed || generation !== this.deckBrowserGeneration || this.claim?.token !== claim.token || this.adapter !== adapter) {
|
|
365
|
+
await browser.stop();
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
257
368
|
this.deckBrowser = browser;
|
|
258
369
|
(this.options.openBrowser ?? openBrowser)(browser.url);
|
|
259
370
|
}
|
|
260
371
|
catch (error) {
|
|
261
|
-
this.
|
|
372
|
+
if (!this.closed && generation === this.deckBrowserGeneration)
|
|
373
|
+
this.status = error instanceof Error ? error.message : String(error);
|
|
262
374
|
}
|
|
263
375
|
finally {
|
|
264
|
-
this.
|
|
376
|
+
if (this.deckBrowserStartingGeneration === generation) {
|
|
377
|
+
this.deckBrowserStarting = false;
|
|
378
|
+
this.deckBrowserStartingGeneration = undefined;
|
|
379
|
+
}
|
|
265
380
|
this.repaint();
|
|
266
381
|
}
|
|
267
382
|
}
|
|
268
|
-
/**
|
|
383
|
+
/** Finalize through this controller's claim-safe lifecycle before HTTP acks. */
|
|
384
|
+
async finalizeDeckBrowser(dir, token, generation, responses) {
|
|
385
|
+
// The generation advances synchronously at take-back's ownership boundary.
|
|
386
|
+
// A request that reaches the listener after that point must fail before it
|
|
387
|
+
// can write, even while stop() is still closing HTTP connections.
|
|
388
|
+
if (this.deckBrowserTakingBack || generation !== this.deckBrowserGeneration || this.claim?.dir !== dir || this.claim.token !== token) {
|
|
389
|
+
throw new Error('browser handoff no longer owns this ticket');
|
|
390
|
+
}
|
|
391
|
+
// The server's HTTP single-assignment marker is published after its
|
|
392
|
+
// finalizer resolves, so simultaneous tabs can both reach us first. Share
|
|
393
|
+
// the first finalizer rather than starting another write or replacing the
|
|
394
|
+
// promise take-back must wait on.
|
|
395
|
+
if (this.deckBrowserFinalizing !== undefined)
|
|
396
|
+
return this.deckBrowserFinalizing;
|
|
397
|
+
const finalizing = (async () => {
|
|
398
|
+
await this.finishDeck(dir, responses, token);
|
|
399
|
+
const result = readTicketResult(dir);
|
|
400
|
+
if (result?.kind !== 'deck')
|
|
401
|
+
throw new Error('ticket did not produce a deck result');
|
|
402
|
+
return { completedAt: result.completedAt, responsePath: responsePath(dir) };
|
|
403
|
+
})();
|
|
404
|
+
this.deckBrowserFinalizing = finalizing;
|
|
405
|
+
try {
|
|
406
|
+
return await finalizing;
|
|
407
|
+
}
|
|
408
|
+
finally {
|
|
409
|
+
if (this.deckBrowserFinalizing === finalizing)
|
|
410
|
+
this.deckBrowserFinalizing = undefined;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/** The browser has finalized through the controller; reconcile its owner delivery. */
|
|
269
414
|
async finishDeckBrowser(dir, browser) {
|
|
270
415
|
if (this.deckBrowser === browser)
|
|
271
416
|
this.deckBrowser = undefined;
|
|
@@ -279,14 +424,29 @@ export class InboxController {
|
|
|
279
424
|
/** Return browser authority to the terminal deck without changing its draft. */
|
|
280
425
|
async takeBackDeckBrowser() {
|
|
281
426
|
const browser = this.deckBrowser;
|
|
282
|
-
if (browser === undefined)
|
|
427
|
+
if (browser === undefined || this.deckBrowserTakingBack)
|
|
283
428
|
return;
|
|
284
|
-
|
|
429
|
+
// Invalidate newly-arriving browser submits before awaiting anything. Keep
|
|
430
|
+
// the handoff mounted and terminal input blocked until its running submit
|
|
431
|
+
// (if any) has conclusively won or failed and the listener is gone.
|
|
432
|
+
this.deckBrowserTakingBack = true;
|
|
433
|
+
this.deckBrowserGeneration++;
|
|
285
434
|
try {
|
|
435
|
+
// A request that crossed the server boundary before this ownership change
|
|
436
|
+
// must own its normal finish path: it includes the HTTP 200 flush and
|
|
437
|
+
// onSubmit convergence before it stops the listener. Do not force-close
|
|
438
|
+
// that response after merely observing controller persistence.
|
|
439
|
+
const lifecycle = browser.pendingSubmitLifecycle?.();
|
|
440
|
+
if (lifecycle !== undefined && await lifecycle.catch(() => false))
|
|
441
|
+
return;
|
|
286
442
|
await browser.requestTakeBack();
|
|
287
443
|
await browser.stop();
|
|
288
444
|
}
|
|
289
445
|
finally {
|
|
446
|
+
if (this.deckBrowser === browser)
|
|
447
|
+
this.deckBrowser = undefined;
|
|
448
|
+
this.deckBrowserTakingBack = false;
|
|
449
|
+
this.rescan();
|
|
290
450
|
this.repaint(true);
|
|
291
451
|
}
|
|
292
452
|
}
|
|
@@ -294,6 +454,9 @@ export class InboxController {
|
|
|
294
454
|
if (this.closed)
|
|
295
455
|
return;
|
|
296
456
|
this.closed = true;
|
|
457
|
+
this.deckBrowserGeneration++;
|
|
458
|
+
this.deckBrowserTakingBack = false;
|
|
459
|
+
this.deckBrowserStarting = false;
|
|
297
460
|
void this.reviewAdapter?.stop();
|
|
298
461
|
const browser = this.deckBrowser;
|
|
299
462
|
this.deckBrowser = undefined;
|
|
@@ -365,7 +528,26 @@ export class InboxController {
|
|
|
365
528
|
this.reconcileRoots();
|
|
366
529
|
this.repaint();
|
|
367
530
|
}
|
|
368
|
-
/**
|
|
531
|
+
/** The controller owns the terminal handoff and repaint around $EDITOR. */
|
|
532
|
+
editActiveDeckInput() {
|
|
533
|
+
const buffer = this.adapter?.inputBuffer();
|
|
534
|
+
if (buffer === undefined)
|
|
535
|
+
return;
|
|
536
|
+
this.suspendForChild();
|
|
537
|
+
let result = { text: buffer };
|
|
538
|
+
try {
|
|
539
|
+
result = editBufferInEditor(buffer);
|
|
540
|
+
}
|
|
541
|
+
finally {
|
|
542
|
+
this.resumeAfterChild();
|
|
543
|
+
this.adapter?.setInputBuffer(result.text);
|
|
544
|
+
this.resize();
|
|
545
|
+
if (result.error !== undefined)
|
|
546
|
+
this.status = result.error;
|
|
547
|
+
this.repaint(true);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
/** Give the raw TTY to a child process (native review editor or $EDITOR). */
|
|
369
551
|
suspendForChild() {
|
|
370
552
|
this.suspended = true;
|
|
371
553
|
if (this.stdinListener !== undefined)
|
|
@@ -407,8 +589,13 @@ export class InboxController {
|
|
|
407
589
|
})();
|
|
408
590
|
}
|
|
409
591
|
leaveDetail(release = true) {
|
|
592
|
+
this.deckBrowserGeneration++;
|
|
593
|
+
// A stale listener will stop itself after its await, but this controller
|
|
594
|
+
// may immediately claim another ticket and start a fresh browser handoff.
|
|
595
|
+
this.deckBrowserStarting = false;
|
|
410
596
|
this.adapter?.close();
|
|
411
597
|
this.adapter = undefined;
|
|
598
|
+
this.activeDeck = undefined;
|
|
412
599
|
this.selectedWatcher?.close();
|
|
413
600
|
this.selectedWatcher = undefined;
|
|
414
601
|
if (release && this.claim !== undefined)
|
|
@@ -419,9 +606,15 @@ export class InboxController {
|
|
|
419
606
|
select(index) {
|
|
420
607
|
if (this.items.length === 0)
|
|
421
608
|
return;
|
|
422
|
-
|
|
609
|
+
const next = Math.max(0, Math.min(index, this.items.length - 1));
|
|
610
|
+
if (next !== this.selectedIndex)
|
|
611
|
+
this.previewScrollOffset = 0;
|
|
612
|
+
this.selectedIndex = next;
|
|
423
613
|
this.selectedDir = this.items[this.selectedIndex].dir;
|
|
424
614
|
}
|
|
615
|
+
scrollPreview(delta) {
|
|
616
|
+
this.previewScrollOffset = Math.max(0, this.previewScrollOffset + delta);
|
|
617
|
+
}
|
|
425
618
|
detailSize() {
|
|
426
619
|
const layout = inboxLayout(this.cols, this.rows, this.screen);
|
|
427
620
|
return { cols: Math.max(1, layout.detailWidth - 2), rows: layout.height };
|
|
@@ -434,6 +627,15 @@ export class InboxController {
|
|
|
434
627
|
if (this.adapter !== undefined)
|
|
435
628
|
return this.adapter.render();
|
|
436
629
|
const selected = this.items[this.selectedIndex];
|
|
630
|
+
if (selected === undefined)
|
|
631
|
+
return this.previewViewport(this.passiveDetailLines(width), width, rows);
|
|
632
|
+
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`];
|
|
635
|
+
return [...this.previewViewport(this.passiveDetailLines(width), width, Math.max(0, rows - footer.length)), ...footer.map((line) => clipLine(line, width))];
|
|
636
|
+
}
|
|
637
|
+
passiveDetailLines(width) {
|
|
638
|
+
const selected = this.items[this.selectedIndex];
|
|
437
639
|
if (selected === undefined)
|
|
438
640
|
return [` ${DIM}Select a pending interaction.${RESET}`];
|
|
439
641
|
const lines = [` ${BOLD}${CYAN}${selected.title}${RESET}`, '', ` ${DIM}${selected.kind} · ${sourceLabel(selected.source)}${RESET}`];
|
|
@@ -454,18 +656,18 @@ export class InboxController {
|
|
|
454
656
|
else
|
|
455
657
|
for (const rendered of renderMarkdown(md, Math.max(1, width - 2)))
|
|
456
658
|
lines.push(` ${rendered}`);
|
|
457
|
-
lines.push('', ` ${DIM}Enter${RESET} start review ${DIM}j/k${RESET} select ${DIM}q${RESET} close`);
|
|
458
|
-
while (lines.length < rows)
|
|
459
|
-
lines.push('');
|
|
460
|
-
// Rendered markdown carries ANSI; slicing by column count would sever
|
|
461
|
-
// escape sequences, so review preview lines are returned unsliced.
|
|
462
|
-
return lines;
|
|
463
659
|
}
|
|
464
|
-
|
|
660
|
+
else {
|
|
465
661
|
const deck = readJson(deckPath(selected.dir));
|
|
466
662
|
if (deck !== null) {
|
|
467
663
|
for (const interaction of deck.interactions) {
|
|
468
664
|
lines.push('', ` ${BOLD}${interaction.title}${RESET}`);
|
|
665
|
+
if (interaction.subtitle)
|
|
666
|
+
for (const rendered of renderMarkdown(interaction.subtitle, Math.max(1, width - 2)))
|
|
667
|
+
lines.push(` ${rendered}`);
|
|
668
|
+
if (interaction.body)
|
|
669
|
+
for (const rendered of renderMarkdown(interaction.body, Math.max(1, width - 2)))
|
|
670
|
+
lines.push(` ${rendered}`);
|
|
469
671
|
for (const option of interaction.options)
|
|
470
672
|
lines.push(` ${DIM}• ${option.label}${RESET}`);
|
|
471
673
|
}
|
|
@@ -473,12 +675,33 @@ export class InboxController {
|
|
|
473
675
|
lines.push('', ` ${DIM}${Array.isArray(saved) ? saved.length : 0} saved responses${RESET}`);
|
|
474
676
|
}
|
|
475
677
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
678
|
+
return lines.map((line) => clipLine(line, width));
|
|
679
|
+
}
|
|
680
|
+
previewViewport(lines, width, rows) {
|
|
681
|
+
if (rows < 1)
|
|
682
|
+
return [];
|
|
683
|
+
const maxOffset = Math.max(0, lines.length - Math.max(1, rows - 1));
|
|
684
|
+
this.previewScrollOffset = Math.min(this.previewScrollOffset, maxOffset);
|
|
685
|
+
const start = this.previewScrollOffset;
|
|
686
|
+
const hasAbove = start > 0;
|
|
687
|
+
// Reserve an indicator row before selecting content so a remaining tail is
|
|
688
|
+
// always signalled instead of silently disappearing below the viewport.
|
|
689
|
+
let contentRows = rows - (hasAbove ? 1 : 0);
|
|
690
|
+
let end = Math.min(lines.length, start + contentRows);
|
|
691
|
+
const hasBelow = end < lines.length;
|
|
692
|
+
if (hasBelow) {
|
|
693
|
+
contentRows--;
|
|
694
|
+
end = Math.min(lines.length, start + Math.max(0, contentRows));
|
|
695
|
+
}
|
|
696
|
+
const out = [];
|
|
697
|
+
if (hasAbove)
|
|
698
|
+
out.push(` ${DIM}↑ more above${RESET}`);
|
|
699
|
+
out.push(...lines.slice(start, end));
|
|
700
|
+
if (hasBelow)
|
|
701
|
+
out.push(` ${DIM}↓ more below${RESET}`);
|
|
702
|
+
while (out.length < rows)
|
|
703
|
+
out.push('');
|
|
704
|
+
return out.map((line) => clipLine(line, width));
|
|
482
705
|
}
|
|
483
706
|
withStatus(lines) {
|
|
484
707
|
if (this.status === undefined || this.rows < 1)
|
|
@@ -521,10 +744,15 @@ export class InboxController {
|
|
|
521
744
|
this.selectedWatcher?.close();
|
|
522
745
|
try {
|
|
523
746
|
this.selectedWatcher = watch(dir, (_event, file) => {
|
|
524
|
-
if (file === 'deck.json')
|
|
747
|
+
if (file === 'deck.json') {
|
|
525
748
|
this.reloadSelectedDeck();
|
|
526
|
-
|
|
527
|
-
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (file === 'followup-result.json' || file === 'followup-request.json') {
|
|
752
|
+
this.refreshFollowUp(dir);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
this.invalidate();
|
|
528
756
|
});
|
|
529
757
|
}
|
|
530
758
|
catch {
|
|
@@ -535,6 +763,9 @@ export class InboxController {
|
|
|
535
763
|
function visibleWidth(line) { return line.replace(/\x1b\[[0-9;]*m/g, '').length; }
|
|
536
764
|
/** M-i (Option/Alt+I) reaches the controller as the two-byte ESC-i sequence. */
|
|
537
765
|
function isToggleCloseChord(input) { return input === '\x1bi' || input === '\x1bI'; }
|
|
766
|
+
function isAnswerBearingDeck(deck) {
|
|
767
|
+
return deck.interactions.some((interaction) => interaction.kind !== 'notify');
|
|
768
|
+
}
|
|
538
769
|
/** Prefer a human-meaningful source label, falling back to the raw node id
|
|
539
770
|
* before an opaque "unknown source" — a crouter ticket always carries nodeId. */
|
|
540
771
|
function sourceLabel(source) { return source.sessionName ?? source.askedBy ?? source.nodeId ?? 'unknown source'; }
|
|
@@ -6,9 +6,16 @@ export declare function progressPath(dir: string): string;
|
|
|
6
6
|
export declare function claimPath(dir: string): string;
|
|
7
7
|
export declare function deliveryPath(dir: string): string;
|
|
8
8
|
export declare function deliveryErrorPath(dir: string): string;
|
|
9
|
+
export declare function followupRequestPath(dir: string): string;
|
|
10
|
+
export declare function followupResultPath(dir: string): string;
|
|
9
11
|
export declare function visualsDir(dir: string): string;
|
|
10
12
|
export declare function visualMdPath(dir: string, id: string): string;
|
|
11
13
|
export declare function visualAnsiPath(dir: string, id: string): string;
|
|
14
|
+
/** Spawns a handler `{command,args}` with `event` as JSON on stdin. Exit 0
|
|
15
|
+
* acknowledges; a nonzero exit, spawn error, or 30s timeout rejects with an
|
|
16
|
+
* Error carrying the handler's captured stderr. Shared by completion delivery
|
|
17
|
+
* and follow-up dispatch — both invoke a registered handler the same way. */
|
|
18
|
+
export declare function runHandler(command: string, args: string[], event: unknown): Promise<void>;
|
|
12
19
|
export type InteractionState = 'pending' | 'claimed' | 'resolved' | 'missing';
|
|
13
20
|
export declare function interactionState(dir: string): InteractionState;
|
|
14
21
|
export declare function isResolved(dir: string): boolean;
|
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';
|