@crouton-kit/humanloop 0.3.38 → 0.4.0

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 (46) hide show
  1. package/dist/api.d.ts +3 -2
  2. package/dist/api.js +2 -2
  3. package/dist/browser/server.d.ts +15 -5
  4. package/dist/browser/server.js +110 -14
  5. package/dist/cli.js +0 -0
  6. package/dist/editor/roundtrip.d.ts +5 -0
  7. package/dist/editor/roundtrip.js +36 -0
  8. package/dist/inbox/completion.js +1 -27
  9. package/dist/inbox/controller.d.ts +32 -3
  10. package/dist/inbox/controller.js +427 -35
  11. package/dist/inbox/convention.d.ts +9 -2
  12. package/dist/inbox/convention.js +51 -3
  13. package/dist/inbox/deck-adapter.d.ts +10 -2
  14. package/dist/inbox/deck-adapter.js +23 -12
  15. package/dist/inbox/deck-schema.d.ts +2 -0
  16. package/dist/inbox/deck-schema.js +4 -4
  17. package/dist/inbox/followup.d.ts +57 -0
  18. package/dist/inbox/followup.js +117 -0
  19. package/dist/inbox/registry.d.ts +4 -0
  20. package/dist/inbox/registry.js +9 -4
  21. package/dist/inbox/tickets.d.ts +5 -0
  22. package/dist/inbox/tickets.js +25 -32
  23. package/dist/inbox/tui.d.ts +1 -1
  24. package/dist/inbox/tui.js +58 -25
  25. package/dist/inbox/visual.d.ts +130 -0
  26. package/dist/inbox/visual.js +747 -0
  27. package/dist/index.d.ts +5 -3
  28. package/dist/index.js +2 -1
  29. package/dist/tui/app.d.ts +3 -6
  30. package/dist/tui/app.js +130 -97
  31. package/dist/tui/input.d.ts +5 -1
  32. package/dist/tui/input.js +41 -15
  33. package/dist/tui/log.d.ts +2 -0
  34. package/dist/tui/log.js +53 -0
  35. package/dist/tui/render.js +44 -27
  36. package/dist/tui/terminal.d.ts +1 -0
  37. package/dist/tui/terminal.js +5 -0
  38. package/dist/tui/tmux.js +44 -22
  39. package/dist/types.d.ts +71 -7
  40. package/dist/web/assets/{index-DhbBiRqS.js → index-YFwgZZMg.js} +2 -2
  41. package/dist/web/index.html +1 -1
  42. package/package.json +6 -3
  43. package/dist/conversation/reader.d.ts +0 -6
  44. package/dist/conversation/reader.js +0 -58
  45. package/dist/visuals/generate.d.ts +0 -9
  46. package/dist/visuals/generate.js +0 -79
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
- sessionId?: string;
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
- sessionId: opts.sessionId,
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
  }
@@ -9,12 +9,18 @@ export interface WebServerOpts {
9
9
  * exactly once per server instance, only for the first accepted submit
10
10
  * (later submits get a 409 and never re-fire this). `responsePath`/
11
11
  * `completedAt` are already persisted to disk — the caller converges the
12
- * host surface, it must NOT write the result again. Fired only after the
13
- * submit's own HTTP 200 has finished flushing to its socket, so it is
14
- * safe for this callback to `stop()` the server (which force-closes all
15
- * open sockets) without racing the ack the browser is waiting on.
12
+ * host surface, it must NOT write the result again. For a connected caller,
13
+ * fires only after its HTTP 200 finishes, so this callback can `stop()` the
14
+ * server without racing that ack. If the client disconnects after accepted
15
+ * persistence, it fires without waiting for an impossible finish.
16
16
  */
17
- onSubmit?: (responses: InteractionResponse[], completedAt: string, responsePath: string) => void;
17
+ onSubmit?: (responses: InteractionResponse[], completedAt: string, responsePath: string) => void | Promise<void>;
18
+ /** Controller-owned finalization for a claimed inbox ticket. When supplied,
19
+ * this server never writes response.json itself. */
20
+ finalize?: (responses: InteractionResponse[]) => Promise<{
21
+ completedAt: string;
22
+ responsePath: string;
23
+ }>;
18
24
  }
19
25
  export interface ReviewWebServerOpts {
20
26
  /** Shared review job dir — carries review.vim and progress.json. */
@@ -47,6 +53,10 @@ export interface WebServerHandle {
47
53
  * acks (closed, network hiccup) can't hang take-back forever. Safe to call
48
54
  * with zero open sockets (e.g. the browser was never opened). */
49
55
  requestTakeBack(): Promise<void>;
56
+ /** The accepted submit currently completing its HTTP ack and owner convergence,
57
+ * if one has crossed the server boundary. `true` means the normal submit
58
+ * finish path completed; `false` means finalization was rejected. */
59
+ pendingSubmitLifecycle?(): Promise<boolean> | undefined;
50
60
  /** Stop listening, close all sockets (WS and HTTP), and tear down. */
51
61
  stop(): Promise<void>;
52
62
  }
@@ -4,7 +4,7 @@ import { existsSync } from 'node:fs';
4
4
  import { join, extname, dirname, basename } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { WebSocketServer } from 'ws';
7
- import { deckPath, readJson, writeResponse, clearProgress } from '../inbox/convention.js';
7
+ import { deckPath, progressPath, readJson, writeResponse, clearProgress } from '../inbox/convention.js';
8
8
  import { buildDraftFeedbackResult, buildFinalFeedbackResult, parseFeedbackComments, readReviewDraft, serializeFeedbackResult, writeReviewDraft, writeSubmitFlag, } from '../editor/feedback.js';
9
9
  // See $CRTR_CONTEXT_DIR/phase1-server-contract.md for the deck HTTP/WS API this
10
10
  // module implements — this file is the reference implementation for the deck
@@ -118,6 +118,12 @@ async function startDeckServer(opts) {
118
118
  let deck = opts.deck;
119
119
  const dir = opts.dir;
120
120
  let submitted = null;
121
+ let submitting = false;
122
+ // Created synchronously when an accepted request enters its finalizer, then
123
+ // kept pending through response flush and async owner convergence. A
124
+ // take-back can therefore serialize with the complete submit lifecycle,
125
+ // rather than racing only its persistence portion.
126
+ let pendingSubmitLifecycle;
121
127
  const scaffold = createServerScaffold();
122
128
  scaffold.setHandler(async (req, res) => {
123
129
  const url = req.url ?? '/';
@@ -132,7 +138,9 @@ async function startDeckServer(opts) {
132
138
  const onDisk = readJson(deckPath(dir));
133
139
  if (onDisk !== null)
134
140
  deck = onDisk;
135
- sendJson(res, 200, { dir, deck });
141
+ const progress = readJson(progressPath(dir));
142
+ const responses = Array.isArray(progress?.responses) ? progress.responses : [];
143
+ sendJson(res, 200, { dir, deck, responses });
136
144
  return;
137
145
  }
138
146
  if (url === '/api/submit' && req.method === 'POST') {
@@ -156,22 +164,107 @@ async function startDeckServer(opts) {
156
164
  sendJson(res, 409, { ok: false, error: 'already_submitted', ...submitted });
157
165
  return;
158
166
  }
167
+ if (submitting) {
168
+ sendJson(res, 409, { ok: false, error: 'submit_in_progress' });
169
+ return;
170
+ }
171
+ submitting = true;
159
172
  const responses = parsed.responses;
160
- const completedAt = new Date().toISOString();
161
- // Canonical write — the exact helper the terminal path uses, so nothing
162
- // downstream of response.json can tell which surface produced it.
163
- const responsePath = writeResponse(dir, responses, completedAt, deck);
173
+ let resolveSubmitLifecycle;
174
+ let submitLifecycleSettled = false;
175
+ const settleSubmitLifecycle = (didSubmit) => {
176
+ if (submitLifecycleSettled)
177
+ return;
178
+ submitLifecycleSettled = true;
179
+ resolveSubmitLifecycle(didSubmit);
180
+ };
181
+ pendingSubmitLifecycle = new Promise((resolve) => { resolveSubmitLifecycle = resolve; });
182
+ let completedAt;
183
+ let responsePath;
184
+ // Convergence (owner onSubmit) must run exactly once for a persisted
185
+ // success, regardless of whether the client is still connected. The
186
+ // `finish` gate below only exists to preserve ack-ordering for a live
187
+ // caller; once the client is gone there is no ordering to protect.
188
+ let convergenceStarted = false;
189
+ const runConvergence = () => {
190
+ if (convergenceStarted)
191
+ return;
192
+ convergenceStarted = true;
193
+ void Promise.resolve(opts.onSubmit?.(responses, completedAt, responsePath)).then(() => settleSubmitLifecycle(true), () => settleSubmitLifecycle(false));
194
+ };
195
+ // Observe client disconnect BEFORE awaiting the finalizer. If the
196
+ // client leaves while finalization is still pending we must NOT declare
197
+ // the accepted submit lost — the finalizer may still persist, in which
198
+ // case convergence must run. Defer judgment until the finalizer settles.
199
+ let finalizePending = true;
200
+ let successReady = false;
201
+ let responseFinished = false;
202
+ let clientGone = false;
203
+ const onResponseFinished = () => {
204
+ responseFinished = true;
205
+ if (successReady && submitted !== null)
206
+ runConvergence();
207
+ };
208
+ const onClientGone = () => {
209
+ // `writableFinished` only means end() was called; a peer can vanish
210
+ // before the `finish` event. Only finish proves the live caller got
211
+ // through the response boundary.
212
+ if (responseFinished)
213
+ return;
214
+ if (finalizePending || !successReady) {
215
+ clientGone = true; // decide once finalization and convergence broadcast settle
216
+ return;
217
+ }
218
+ if (submitted !== null) {
219
+ // Accepted submit persisted but the client left before its body
220
+ // flushed — converge once; no live caller remains to protect.
221
+ runConvergence();
222
+ }
223
+ else {
224
+ // The finalizer rejected: the submit was genuinely lost.
225
+ settleSubmitLifecycle(false);
226
+ }
227
+ };
228
+ req.once('aborted', onClientGone);
229
+ res.once('finish', onResponseFinished);
230
+ res.once('close', onClientGone);
231
+ res.once('error', onClientGone);
232
+ try {
233
+ if (opts.finalize !== undefined) {
234
+ ({ completedAt, responsePath } = await opts.finalize(responses));
235
+ }
236
+ else {
237
+ // Standalone browser handoff retains its direct local persistence.
238
+ completedAt = new Date().toISOString();
239
+ responsePath = writeResponse(dir, responses, completedAt, deck);
240
+ clearProgress(dir);
241
+ }
242
+ }
243
+ catch (error) {
244
+ finalizePending = false;
245
+ submitting = false;
246
+ settleSubmitLifecycle(false);
247
+ sendJson(res, 400, { error: 'invalid_submit', message: error instanceof Error ? error.message : String(error) });
248
+ return;
249
+ }
250
+ finalizePending = false;
164
251
  submitted = { responsePath, completedAt };
165
- clearProgress(dir);
166
- // Await the flush BEFORE registering the finish callback that can
167
- // trigger caller-side stop() — that ordering is what closes the race
168
- // between a lost WS frame and the teardown it precedes.
252
+ // Broadcast convergence before permitting owner cleanup, so connected
253
+ // observers receive this state transition before the server can close.
169
254
  await scaffold.broadcast({ type: 'converged' });
255
+ successReady = true;
256
+ if (clientGone) {
257
+ // The client disconnected during finalization; converge now rather
258
+ // than waiting for a `finish` that will never fire. The 200 is
259
+ // best-effort — the socket may already be closed.
260
+ runConvergence();
261
+ sendJson(res, 200, { ok: true, responsePath, completedAt });
262
+ return;
263
+ }
170
264
  // Ack-ordering guarantee: the HTTP caller must always receive its 200
171
- // body before any lifecycle teardown can close its socket.
172
- res.once('finish', () => {
173
- opts.onSubmit?.(responses, completedAt, responsePath);
174
- });
265
+ // body before any lifecycle teardown can close its socket. The finish
266
+ // observer registered above converges then; `onClientGone` converges if
267
+ // the peer disappears before finish.
175
268
  sendJson(res, 200, { ok: true, responsePath, completedAt });
176
269
  return;
177
270
  }
@@ -192,6 +285,9 @@ async function startDeckServer(opts) {
192
285
  async requestTakeBack() {
193
286
  await scaffold.broadcast({ type: 'taken-back' });
194
287
  },
288
+ pendingSubmitLifecycle() {
289
+ return pendingSubmitLifecycle;
290
+ },
195
291
  stop() {
196
292
  return scaffold.stop();
197
293
  },
package/dist/cli.js CHANGED
File without changes
@@ -0,0 +1,5 @@
1
+ /** Run $EDITOR against a temporary buffer. Terminal ownership stays with the host. */
2
+ export declare function editBufferInEditor(buffer: string): {
3
+ text: string;
4
+ error?: string;
5
+ };
@@ -0,0 +1,36 @@
1
+ import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ /** Run $EDITOR against a temporary buffer. Terminal ownership stays with the host. */
7
+ export function editBufferInEditor(buffer) {
8
+ const tmpFile = join(tmpdir(), `hl-input-${randomUUID()}.txt`);
9
+ const editor = process.env.EDITOR || 'vi';
10
+ try {
11
+ writeFileSync(tmpFile, buffer);
12
+ // $EDITOR may be a shell fragment such as "code --wait".
13
+ const result = spawnSync('/bin/sh', ['-c', `${editor} "$1"`, 'sh', tmpFile], { stdio: 'inherit' });
14
+ if (result.error)
15
+ return { text: buffer, error: `$EDITOR ("${editor}") failed to launch: ${result.error.message}` };
16
+ if (result.signal !== null)
17
+ return { text: buffer, error: `$EDITOR ("${editor}") was killed by signal ${result.signal}` };
18
+ if (result.status === 127 || result.status === 126)
19
+ return { text: buffer, error: `$EDITOR ("${editor}") failed to launch (shell exit ${result.status})` };
20
+ if (result.status !== 0)
21
+ return { text: buffer, error: `$EDITOR ("${editor}") exited with status ${result.status}` };
22
+ let text = readFileSync(tmpFile, 'utf8');
23
+ if (text.endsWith('\n') && !buffer.endsWith('\n'))
24
+ text = text.slice(0, -1);
25
+ return { text };
26
+ }
27
+ catch (error) {
28
+ return { text: buffer, error: `$EDITOR round-trip failed: ${error instanceof Error ? error.message : String(error)}` };
29
+ }
30
+ finally {
31
+ try {
32
+ unlinkSync(tmpFile);
33
+ }
34
+ catch { /* best-effort cleanup */ }
35
+ }
36
+ }
@@ -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,4 +1,4 @@
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
4
  export interface InboxControllerOptions {
@@ -9,6 +9,7 @@ export interface InboxControllerOptions {
9
9
  completeDeck?: (dir: string, responses: InteractionResponse[], token: string) => Promise<unknown>;
10
10
  startDeckBrowser?: typeof startWebServer;
11
11
  openBrowser?: (url: string) => void;
12
+ visualProvider?: VisualProvider;
12
13
  }
13
14
  type Screen = 'list' | 'detail';
14
15
  /** One terminal owner for scanning, stable selection, claims, and frame diffs. */
@@ -19,13 +20,25 @@ export declare class InboxController {
19
20
  private items;
20
21
  private selectedDir;
21
22
  private selectedIndex;
23
+ /** Scroll state belongs to the passive preview, never to an editable deck. */
24
+ private previewScrollOffset;
22
25
  private screen;
23
26
  private adapter;
27
+ private activeDeck;
24
28
  private reviewAdapter;
25
29
  private deckBrowser;
30
+ /** Blocks terminal input until browser finalization/shutdown is quiescent. */
31
+ private deckBrowserTakingBack;
32
+ /** The single accepted submit that crossed the browser boundary. */
33
+ private deckBrowserFinalizing;
26
34
  private deckBrowserStarting;
35
+ private deckBrowserStartingGeneration;
36
+ /** Invalidates an in-flight asynchronous browser start when ownership ends. */
37
+ private deckBrowserGeneration;
27
38
  private claim;
28
39
  private reconciling;
40
+ private visualReconciling;
41
+ private readonly visualCleanupExecutor;
29
42
  private suspended;
30
43
  private submittingDir;
31
44
  private stdinListener;
@@ -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
- /** The browser has atomically published the response; reconcile its owner delivery. */
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
- /** Give the raw TTY to a child process (native review editor). */
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;
@@ -73,10 +94,18 @@ export declare class InboxController {
73
94
  /** Owner-boundary reconciliation for resolved results still lacking an ack.
74
95
  * Guarded so overlapping fs events cannot stack concurrent scans. */
75
96
  private reconcileRoots;
97
+ /** Reconciliation owns stale-claim retirement and durable cleanup, never a Visual start. */
98
+ private reconcileVisualWork;
99
+ /** Automatic ticket capability is deliberately marker + current-root-handler only. */
100
+ private visualProviderFor;
101
+ private startTicketVisual;
76
102
  private leaveDetail;
77
103
  private select;
104
+ private scrollPreview;
78
105
  private detailSize;
79
106
  private detailLines;
107
+ private passiveDetailLines;
108
+ private previewViewport;
80
109
  private withStatus;
81
110
  private repaint;
82
111
  private watchRoots;