@crouton-kit/humanloop 0.4.0 → 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/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))
@@ -10,6 +10,9 @@ export interface InboxControllerOptions {
10
10
  startDeckBrowser?: typeof startWebServer;
11
11
  openBrowser?: (url: string) => void;
12
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;
13
16
  }
14
17
  type Screen = 'list' | 'detail';
15
18
  /** One terminal owner for scanning, stable selection, claims, and frame diffs. */
@@ -33,12 +36,10 @@ export declare class InboxController {
33
36
  private deckBrowserFinalizing;
34
37
  private deckBrowserStarting;
35
38
  private deckBrowserStartingGeneration;
39
+ private focusingSource;
36
40
  /** Invalidates an in-flight asynchronous browser start when ownership ends. */
37
41
  private deckBrowserGeneration;
38
42
  private claim;
39
- private reconciling;
40
- private visualReconciling;
41
- private readonly visualCleanupExecutor;
42
43
  private suspended;
43
44
  private submittingDir;
44
45
  private stdinListener;
@@ -91,11 +92,6 @@ 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
- /** Owner-boundary reconciliation for resolved results still lacking an ack.
95
- * Guarded so overlapping fs events cannot stack concurrent scans. */
96
- private reconcileRoots;
97
- /** Reconciliation owns stale-claim retirement and durable cleanup, never a Visual start. */
98
- private reconcileVisualWork;
99
95
  /** Automatic ticket capability is deliberately marker + current-root-handler only. */
100
96
  private visualProviderFor;
101
97
  private startTicketVisual;
@@ -104,6 +100,11 @@ export declare class InboxController {
104
100
  private scrollPreview;
105
101
  private detailSize;
106
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;
107
108
  private passiveDetailLines;
108
109
  private previewViewport;
109
110
  private withStatus;
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, watch } from 'node:fs';
2
- import { join } from 'node:path';
2
+ import { basename, join } from 'node:path';
3
3
  import { getTerminalSize, parseKeypress, restoreTerminal, setupTerminal } from '../tui/terminal.js';
4
4
  import { diffFrame } from '../tui/render.js';
5
5
  import { renderMarkdown } from '../render/termrender.js';
@@ -10,72 +10,17 @@ import { BOLD, CYAN, DIM, GRAY, RESET, YELLOW, clipLine } from '../tui/ansi.js';
10
10
  import { buildInboxLines } from './tui.js';
11
11
  import { inboxLayout } from './layout.js';
12
12
  import { scanInbox } from './scan.js';
13
- import { inboxRootsDirectory, listInboxRoots, registeredInboxRoot } from './registry.js';
13
+ import { inboxActivityPath, inboxRootsDirectory, inboxStateDirectory, listInboxRoots, registeredInboxRoot } from './registry.js';
14
14
  import { claimTicket, heartbeatClaim, releaseClaim } from './claim.js';
15
15
  import { completeDeck, readTicketResult, ticketRoot } from './tickets.js';
16
- import { reconcileCompletions } from './completion.js';
17
- import { clearProgress, deckPath, progressPath, readJson, responsePath, reviewPath, visualsDir } from './convention.js';
16
+ import { clearProgress, deckPath, progressPath, readJson, responsePath, reviewPath, runHandler, visualsDir } from './convention.js';
18
17
  import { DeckAdapter } from './deck-adapter.js';
19
18
  import { validateDeck } from './deck-schema.js';
20
19
  import { ReviewAdapter } from './review-adapter.js';
21
20
  import { editBufferInEditor } from '../editor/roundtrip.js';
22
21
  import { cancelFollowUp, readFollowUp, requestFollowUp } from './followup.js';
23
- import { cancelVisualRequest, dispatchVisualCleanup, listVisualCleanupObligationsForRoot, readVisualResult, reconcileStaleVisualRequestsForRoot, reconcileVisualRequestsForTicket, startVisualRequest, VISUAL_CAPABILITY, } from './visual.js';
24
- /** One controller-owned retry loop for durable cancellation only. */
25
- class VisualCleanupExecutor {
26
- roots;
27
- timer;
28
- dispatching = new Set();
29
- stopped = false;
30
- constructor(roots) {
31
- this.roots = roots;
32
- }
33
- reconcile() {
34
- if (this.stopped)
35
- return;
36
- let tasks = [];
37
- for (const root of this.roots()) {
38
- try {
39
- tasks.push(...listVisualCleanupObligationsForRoot(root));
40
- }
41
- catch { /* the next root reconciliation retries durable work */ }
42
- }
43
- tasks = tasks.filter((task) => !this.dispatching.has(this.key(task)));
44
- const now = Date.now();
45
- for (const task of tasks.filter((candidate) => Date.parse(candidate.nextAttemptAt) <= now))
46
- this.dispatch(task);
47
- this.arm(tasks.filter((task) => !this.dispatching.has(this.key(task))));
48
- }
49
- stop() {
50
- this.stopped = true;
51
- if (this.timer !== undefined)
52
- clearTimeout(this.timer);
53
- this.timer = undefined;
54
- }
55
- dispatch(task) {
56
- const key = this.key(task);
57
- if (this.dispatching.has(key))
58
- return;
59
- this.dispatching.add(key);
60
- void dispatchVisualCleanup(task.root, task.dir, task.requestId).catch(() => undefined).finally(() => {
61
- this.dispatching.delete(key);
62
- this.reconcile();
63
- });
64
- }
65
- arm(tasks) {
66
- if (this.timer !== undefined)
67
- clearTimeout(this.timer);
68
- this.timer = undefined;
69
- const earliest = tasks.reduce((next, task) => {
70
- const due = Date.parse(task.nextAttemptAt);
71
- return Number.isFinite(due) && (next === undefined || due < next) ? due : next;
72
- }, undefined);
73
- if (earliest === undefined)
74
- return;
75
- this.timer = setTimeout(() => { this.timer = undefined; this.reconcile(); }, Math.max(0, earliest - Date.now()));
76
- }
77
- key(task) { return `${task.root}\u0000${task.dir}\u0000${task.requestId}`; }
78
- }
22
+ import { cancelVisualRequest, readVisualResult, reconcileVisualRequestsForTicket, startVisualRequest, VISUAL_CAPABILITY, } from './visual.js';
23
+ import { kickInboxMaintenance } from './maintenance.js';
79
24
  /** One terminal owner for scanning, stable selection, claims, and frame diffs. */
80
25
  export class InboxController {
81
26
  options;
@@ -97,12 +42,10 @@ export class InboxController {
97
42
  deckBrowserFinalizing;
98
43
  deckBrowserStarting = false;
99
44
  deckBrowserStartingGeneration;
45
+ focusingSource = false;
100
46
  /** Invalidates an in-flight asynchronous browser start when ownership ends. */
101
47
  deckBrowserGeneration = 0;
102
48
  claim;
103
- reconciling = false;
104
- visualReconciling = false;
105
- visualCleanupExecutor;
106
49
  suspended = false;
107
50
  submittingDir;
108
51
  stdinListener;
@@ -122,9 +65,7 @@ export class InboxController {
122
65
  const size = getTerminalSize();
123
66
  this.cols = options.cols ?? size.cols;
124
67
  this.rows = options.rows ?? size.rows;
125
- this.visualCleanupExecutor = new VisualCleanupExecutor(() => this.resolvedRoots());
126
68
  this.rescan();
127
- this.reconcileVisualWork();
128
69
  }
129
70
  snapshot() {
130
71
  return { items: [...this.items], selectedDir: this.selectedDir, screen: this.screen, inputBuffer: this.adapter?.inputBuffer() };
@@ -213,6 +154,10 @@ export class InboxController {
213
154
  return;
214
155
  }
215
156
  if (this.screen === 'detail' && this.adapter !== undefined) {
157
+ if ((input === 'g' || input === 'G') && this.adapter.canAcceptHostKeys()) {
158
+ void this.focusSelectedSource();
159
+ return;
160
+ }
216
161
  if ((input === 'w' || input === 'W') && this.adapter.canAcceptHostKeys()) {
217
162
  void this.openDeckBrowser();
218
163
  return;
@@ -236,6 +181,8 @@ export class InboxController {
236
181
  this.select(this.selectedIndex + 1);
237
182
  else if (input === 'k' || key.upArrow)
238
183
  this.select(this.selectedIndex - 1);
184
+ else if (input === 'g' || input === 'G')
185
+ void this.focusSelectedSource();
239
186
  else if (key.return || input === 'a')
240
187
  this.activate();
241
188
  this.repaint();
@@ -268,7 +215,7 @@ export class InboxController {
268
215
  // A newly acquired claim makes every older running generation stale
269
216
  // before the panel can mint its own work. This only dispatches cleanup.
270
217
  const retirement = reconcileVisualRequestsForTicket(root, item.dir, claim.token);
271
- void retirement.delivery.finally(() => this.visualCleanupExecutor.reconcile());
218
+ void retirement.delivery.finally(kickInboxMaintenance);
272
219
  }
273
220
  // Notifications use the same canonical deck panel as every other deck:
274
221
  // opening is not acknowledgement; panel completion is.
@@ -331,7 +278,6 @@ export class InboxController {
331
278
  else {
332
279
  this.resumeAfterChild();
333
280
  this.rescan();
334
- this.reconcileRoots();
335
281
  this.repaint(true);
336
282
  }
337
283
  }
@@ -486,7 +432,6 @@ export class InboxController {
486
432
  if (this.claim?.dir === dir)
487
433
  this.leaveDetail();
488
434
  this.rescan();
489
- this.reconcileRoots();
490
435
  this.repaint();
491
436
  }
492
437
  /** Return browser authority to the terminal deck without changing its draft. */
@@ -530,7 +475,6 @@ export class InboxController {
530
475
  this.deckBrowser = undefined;
531
476
  void browser?.stop();
532
477
  this.leaveDetail();
533
- this.visualCleanupExecutor.stop();
534
478
  for (const watcher of this.watchers)
535
479
  watcher.close();
536
480
  this.watchers = [];
@@ -542,10 +486,9 @@ export class InboxController {
542
486
  setupTerminal();
543
487
  this.running = true;
544
488
  this.watchRoots();
545
- // Close the crash window between an earlier result publication and its
546
- // handler launch: dispatch every resolved-but-undelivered ticket now.
547
- this.reconcileRoots();
548
489
  this.repaint(true);
490
+ // Crash repair is durable but never part of a human keystroke.
491
+ kickInboxMaintenance();
549
492
  const heartbeat = setInterval(() => {
550
493
  if (this.claim !== undefined)
551
494
  heartbeatClaim(this.claim.dir, this.claim.token);
@@ -594,7 +537,6 @@ export class InboxController {
594
537
  this.submittingDir = undefined;
595
538
  this.leaveDetail(false);
596
539
  this.rescan();
597
- this.reconcileRoots();
598
540
  this.repaint();
599
541
  }
600
542
  /** The controller owns the terminal handoff and repaint around $EDITOR. */
@@ -636,52 +578,6 @@ export class InboxController {
636
578
  resolvedRoots() {
637
579
  return this.options.roots ?? listInboxRoots().filter((root) => root.available).map((root) => root.root);
638
580
  }
639
- /** Owner-boundary reconciliation for resolved results still lacking an ack.
640
- * Guarded so overlapping fs events cannot stack concurrent scans. */
641
- reconcileRoots() {
642
- if (this.reconciling)
643
- return;
644
- this.reconciling = true;
645
- const roots = this.resolvedRoots();
646
- void (async () => {
647
- try {
648
- for (const root of roots) {
649
- try {
650
- await reconcileCompletions(root);
651
- }
652
- catch { /* undelivered stays for the next pass */ }
653
- }
654
- }
655
- finally {
656
- this.reconciling = false;
657
- }
658
- })();
659
- this.reconcileVisualWork();
660
- }
661
- /** Reconciliation owns stale-claim retirement and durable cleanup, never a Visual start. */
662
- reconcileVisualWork() {
663
- if (this.visualReconciling)
664
- return;
665
- this.visualReconciling = true;
666
- const roots = this.resolvedRoots();
667
- void (async () => {
668
- try {
669
- const deliveries = roots.flatMap((root) => {
670
- try {
671
- return reconcileStaleVisualRequestsForRoot(root).map((entry) => entry.delivery);
672
- }
673
- catch {
674
- return [];
675
- }
676
- });
677
- await Promise.all(deliveries);
678
- }
679
- finally {
680
- this.visualReconciling = false;
681
- this.visualCleanupExecutor.reconcile();
682
- }
683
- })();
684
- }
685
581
  /** Automatic ticket capability is deliberately marker + current-root-handler only. */
686
582
  visualProviderFor(dir, deck, claimToken) {
687
583
  // An inline provider is intentional host injection, independent of ticket capability.
@@ -744,7 +640,7 @@ export class InboxController {
744
640
  cancel: () => {
745
641
  watcher?.close();
746
642
  watcher = undefined;
747
- void cancelVisualRequest(root, dir, request.requestId).finally(() => this.visualCleanupExecutor.reconcile());
643
+ void cancelVisualRequest(root, dir, request.requestId).finally(kickInboxMaintenance);
748
644
  },
749
645
  };
750
646
  }
@@ -755,7 +651,6 @@ export class InboxController {
755
651
  this.deckBrowserStarting = false;
756
652
  this.adapter?.close();
757
653
  this.adapter = undefined;
758
- this.visualCleanupExecutor.reconcile();
759
654
  this.activeDeck = undefined;
760
655
  this.selectedWatcher?.close();
761
656
  this.selectedWatcher = undefined;
@@ -790,11 +685,42 @@ export class InboxController {
790
685
  const selected = this.items[this.selectedIndex];
791
686
  if (selected === undefined)
792
687
  return this.previewViewport(this.passiveDetailLines(width), width, rows);
688
+ const focusHint = this.focusAvailable(selected.dir) ? ` ${DIM}g${RESET} chat` : '';
793
689
  const footer = selected.kind === 'deck'
794
- ? [` ${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`]
795
- : [` ${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`];
796
692
  return [...this.previewViewport(this.passiveDetailLines(width), width, Math.max(0, rows - footer.length)), ...footer.map((line) => clipLine(line, width))];
797
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
+ }
798
724
  passiveDetailLines(width) {
799
725
  const selected = this.items[this.selectedIndex];
800
726
  if (selected === undefined)
@@ -888,15 +814,21 @@ export class InboxController {
888
814
  this.frame = diff.nextPrevFrame;
889
815
  }
890
816
  watchRoots() {
891
- for (const root of this.resolvedRoots()) {
892
- try {
893
- this.watchers.push(watch(root, () => { this.invalidate(); this.reconcileRoots(); }));
894
- }
895
- catch { /* unavailable roots remain discoverable through later rescans */ }
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
+ }));
896
827
  }
828
+ catch { /* the state directory appears when the next ticket is submitted */ }
897
829
  if (this.options.roots === undefined) {
898
830
  try {
899
- this.watchers.push(watch(inboxRootsDirectory(), () => this.invalidate()));
831
+ this.watchers.push(watch(inboxRootsDirectory(), () => { this.invalidate(); kickInboxMaintenance(); }));
900
832
  }
901
833
  catch { /* registry appears after the next explicit open */ }
902
834
  }
@@ -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>;
@@ -0,0 +1,145 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { spawn } from 'node:child_process';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { inboxRootsDirectory, listInboxRoots } from './registry.js';
6
+ import { reconcileCompletions } from './completion.js';
7
+ import { dispatchVisualCleanup, listVisualCleanupObligationsForRoot, reconcileStaleVisualRequestsForRoot } from './visual.js';
8
+ const LEASE_STALE_MS = 300_000;
9
+ function leasePath() { return join(dirname(inboxRootsDirectory()), 'maintenance.lock'); }
10
+ function roots() { return listInboxRoots().filter((root) => root.available).map((root) => root.root); }
11
+ function leaseOwner(path) {
12
+ try {
13
+ const parsed = JSON.parse(readFileSync(join(path, 'owner.json'), 'utf8'));
14
+ return typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) ? parsed.pid : undefined;
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ function processIsAlive(pid) {
21
+ try {
22
+ process.kill(pid, 0);
23
+ return true;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ function staleLease(path) {
30
+ const owner = leaseOwner(path);
31
+ if (owner !== undefined)
32
+ return !processIsAlive(owner);
33
+ try {
34
+ return Date.now() - statSync(path).mtimeMs > LEASE_STALE_MS;
35
+ }
36
+ catch {
37
+ return true;
38
+ }
39
+ }
40
+ /** Start one detached repair pass. The lease makes repeated UI gestures free. */
41
+ export function kickInboxMaintenance() {
42
+ const lease = leasePath();
43
+ try {
44
+ mkdirSync(dirname(lease), { recursive: true, mode: 0o700 });
45
+ }
46
+ catch {
47
+ return;
48
+ }
49
+ try {
50
+ mkdirSync(lease, { mode: 0o700 });
51
+ }
52
+ catch {
53
+ if (!staleLease(lease))
54
+ return;
55
+ try {
56
+ rmSync(lease, { recursive: true, force: true });
57
+ mkdirSync(lease, { mode: 0o700 });
58
+ }
59
+ catch {
60
+ return;
61
+ }
62
+ }
63
+ const builtEntry = fileURLToPath(new URL('../cli.js', import.meta.url));
64
+ const sourceEntry = fileURLToPath(new URL('../cli.ts', import.meta.url));
65
+ const entry = existsSync(builtEntry) ? builtEntry : sourceEntry;
66
+ if (!existsSync(entry)) {
67
+ rmSync(lease, { recursive: true, force: true });
68
+ return;
69
+ }
70
+ // A built CLI needs no parent flags. Source-mode launches retain their tsx
71
+ // loader, but always execute Humanloop's CLI rather than the caller's
72
+ // entrypoint (which may be a test or a consuming application).
73
+ const runtimeArgs = entry.endsWith('.ts') ? process.execArgv : [];
74
+ const child = spawn(process.execPath, [...runtimeArgs, entry, 'inbox', '_maintain', '--lease', lease], {
75
+ detached: true,
76
+ stdio: 'ignore',
77
+ });
78
+ if (child.pid === undefined) {
79
+ rmSync(lease, { recursive: true, force: true });
80
+ return;
81
+ }
82
+ try {
83
+ writeFileSync(join(lease, 'owner.json'), JSON.stringify({ pid: child.pid }));
84
+ }
85
+ catch {
86
+ child.kill();
87
+ rmSync(lease, { recursive: true, force: true });
88
+ return;
89
+ }
90
+ child.unref();
91
+ }
92
+ function dueTasks(allRoots) {
93
+ const tasks = [];
94
+ for (const root of allRoots) {
95
+ try {
96
+ tasks.push(...listVisualCleanupObligationsForRoot(root));
97
+ }
98
+ catch { /* malformed historical state is isolated to its root */ }
99
+ }
100
+ return tasks;
101
+ }
102
+ async function repairOnce() {
103
+ const allRoots = roots();
104
+ for (const root of allRoots) {
105
+ try {
106
+ await reconcileCompletions(root);
107
+ }
108
+ catch { /* receipt remains durable for the next pass */ }
109
+ }
110
+ const retirements = allRoots.flatMap((root) => {
111
+ try {
112
+ return reconcileStaleVisualRequestsForRoot(root).map((entry) => entry.delivery);
113
+ }
114
+ catch {
115
+ return [];
116
+ }
117
+ });
118
+ await Promise.all(retirements);
119
+ const tasks = dueTasks(allRoots);
120
+ await Promise.all(tasks.filter((task) => Date.parse(task.nextAttemptAt) <= Date.now()).map((task) => dispatchVisualCleanup(task.root, task.dir, task.requestId)));
121
+ return dueTasks(roots());
122
+ }
123
+ function releaseLease(lease) {
124
+ if (leaseOwner(lease) !== process.pid)
125
+ return;
126
+ rmSync(lease, { recursive: true, force: true });
127
+ }
128
+ /** Run repair and stay alive only until the durable cleanup retry queue is empty. */
129
+ export async function runInboxMaintenance(lease) {
130
+ try {
131
+ while (true) {
132
+ const tasks = await repairOnce();
133
+ const next = tasks.reduce((earliest, task) => {
134
+ const due = Date.parse(task.nextAttemptAt);
135
+ return Number.isFinite(due) && (earliest === undefined || due < earliest) ? due : earliest;
136
+ }, undefined);
137
+ if (next === undefined)
138
+ return;
139
+ await new Promise((resolve) => setTimeout(resolve, Math.max(0, next - Date.now())));
140
+ }
141
+ }
142
+ finally {
143
+ releaseLease(lease);
144
+ }
145
+ }
@@ -10,6 +10,7 @@ export interface InboxRootRegistration {
10
10
  handler?: CompletionHandler;
11
11
  followUpHandler?: CompletionHandler;
12
12
  visualHandler?: CompletionHandler;
13
+ focusHandler?: CompletionHandler;
13
14
  }
14
15
  export interface InboxRootStatus extends InboxRootRegistration {
15
16
  available: boolean;
@@ -20,8 +21,13 @@ export interface RegisterInboxRootOptions {
20
21
  handler?: CompletionHandler;
21
22
  followUpHandler?: CompletionHandler;
22
23
  visualHandler?: CompletionHandler;
24
+ focusHandler?: CompletionHandler;
23
25
  }
26
+ export declare function inboxStateDirectory(): string;
24
27
  export declare function inboxRootsDirectory(): string;
28
+ export declare function inboxActivityPath(): string;
29
+ /** Signal one durable ticket mutation to every open inbox without watching every root. */
30
+ export declare function signalInboxActivity(): void;
25
31
  /** Create/canonicalize a root and claim its user-scoped registration. */
26
32
  export declare function registerInboxRoot(opts: RegisterInboxRootOptions): InboxRootRegistration;
27
33
  /** Removes a matching available root through its real path, or an unavailable record by its stored canonical path. */
@@ -1,10 +1,20 @@
1
- import { chmodSync, existsSync, mkdirSync, readdirSync, realpathSync, unlinkSync } from 'node:fs';
1
+ import { chmodSync, existsSync, mkdirSync, readdirSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { homedir } from 'node:os';
4
4
  import { join, resolve } from 'node:path';
5
5
  import { atomicWriteJson, readJson, withExclusiveDirectoryLock } from './convention.js';
6
6
  function stateHome() { return process.env['XDG_STATE_HOME'] || join(homedir(), '.local', 'state'); }
7
- export function inboxRootsDirectory() { return join(stateHome(), 'humanloop', 'inbox-roots'); }
7
+ export function inboxStateDirectory() { return join(stateHome(), 'humanloop'); }
8
+ export function inboxRootsDirectory() { return join(inboxStateDirectory(), 'inbox-roots'); }
9
+ export function inboxActivityPath() { return join(inboxStateDirectory(), 'inbox-activity'); }
10
+ /** Signal one durable ticket mutation to every open inbox without watching every root. */
11
+ export function signalInboxActivity() {
12
+ try {
13
+ mkdirSync(inboxStateDirectory(), { recursive: true, mode: 0o700 });
14
+ writeFileSync(inboxActivityPath(), `${Date.now()}\n`, { mode: 0o600 });
15
+ }
16
+ catch { /* a later popup scan is still authoritative */ }
17
+ }
8
18
  function recordPath(root) { return join(inboxRootsDirectory(), createHash('sha256').update(root).digest('hex')); }
9
19
  function canonicalRoot(root) { try {
10
20
  return realpathSync(root);
@@ -31,6 +41,7 @@ function validateRegistration(raw) {
31
41
  handler: value.handler === undefined ? undefined : validateHandler(value.handler),
32
42
  followUpHandler: value.followUpHandler === undefined ? undefined : validateHandler(value.followUpHandler),
33
43
  visualHandler: value.visualHandler === undefined ? undefined : validateHandler(value.visualHandler),
44
+ focusHandler: value.focusHandler === undefined ? undefined : validateHandler(value.focusHandler),
34
45
  };
35
46
  }
36
47
  catch {
@@ -51,7 +62,7 @@ export function registerInboxRoot(opts) {
51
62
  throw new Error(`inbox root is already owned by ${existing.owner}`);
52
63
  if (existing !== null && existing.root !== root)
53
64
  throw new Error('inbox root registry hash collision');
54
- const registration = { schema: 'humanloop.inbox-root/v1', root, owner: opts.owner, handler: validateHandler(opts.handler), followUpHandler: validateHandler(opts.followUpHandler), visualHandler: validateHandler(opts.visualHandler) };
65
+ const registration = { schema: 'humanloop.inbox-root/v1', root, owner: opts.owner, handler: validateHandler(opts.handler), followUpHandler: validateHandler(opts.followUpHandler), visualHandler: validateHandler(opts.visualHandler), focusHandler: validateHandler(opts.focusHandler) };
55
66
  atomicWriteJson(path, registration);
56
67
  chmodSync(path, 0o600);
57
68
  return registration;
@@ -4,10 +4,11 @@ import { z } from 'zod';
4
4
  import { buildSummary } from '../summary.js';
5
5
  import { clearProgress, claimPath, deckPath, deliveryErrorPath, deliveryPath, followupRequestPath, followupResultPath, progressPath, publishJsonExclusive, responsePath, reviewPath, visualsDir } from './convention.js';
6
6
  import { validateDeck, validateReviewDescriptor, validateReviewProjection, resolveDeckBodyPaths } from './deck-schema.js';
7
- import { registeredInboxRoot } from './registry.js';
7
+ import { registeredInboxRoot, signalInboxActivity } from './registry.js';
8
8
  import { readTicketClaim, releaseClaimLocked, withTicketLock } from './claim.js';
9
9
  import { dispatchCompletion } from './completion.js';
10
10
  import { cancelVisualRequestsForTicket } from './visual.js';
11
+ import { kickInboxMaintenance } from './maintenance.js';
11
12
  const ticketId = z.string().regex(/^[A-Za-z0-9_-]+$/).min(1).max(128);
12
13
  const responseSchema = z.object({ id: z.string().min(1), selectedOptionId: z.string().optional(), selectedOptionIds: z.array(z.string()).optional(), freetext: z.string().optional(), optionComments: z.record(z.string(), z.string()).optional() }).strict();
13
14
  const feedbackCommentSchema = z.object({ id: z.string().min(1), line: z.number().int().positive(), endLine: z.number().int().positive(), quote: z.string().optional(), colStart: z.number().int().nonnegative().optional(), colEnd: z.number().int().nonnegative().optional(), lineText: z.string(), comment: z.string().min(1), createdAt: z.string().min(1) }).strict();
@@ -105,6 +106,7 @@ export function submitDeck(opts) {
105
106
  const deck = validateDeck(resolveDeckBodyPaths(opts.deck, dir));
106
107
  const stamped = { ...deck, source: { ...(deck.source ?? {}), blockedSince: deck.source?.blockedSince ?? new Date().toISOString() } };
107
108
  publishRequest(deckPath(dir), stamped);
109
+ signalInboxActivity();
108
110
  return { id: opts.id, dir, kind: 'deck' };
109
111
  }
110
112
  catch (error) {
@@ -124,6 +126,7 @@ export function submitReview(opts) {
124
126
  throw new Error(`ticket protocol state already exists: ${dir}`);
125
127
  const descriptor = validateReviewProjection(dir, { schema: 'humanloop.review/v1', file: source, output: resolve(opts.review.output ?? `${dir}/feedback.json`), title: opts.review.title, source: opts.review.source, blockedSince: opts.review.blockedSince ?? new Date().toISOString() });
126
128
  publishRequest(reviewPath(dir), descriptor);
129
+ signalInboxActivity();
127
130
  return { id: opts.id, dir, kind: 'review' };
128
131
  }
129
132
  catch (error) {
@@ -170,23 +173,26 @@ function requireClaimOwnership(dir, token) {
170
173
  function clearOwnedWork(dir, claimToken) { clearProgress(dir); releaseClaimLocked(dir, claimToken); }
171
174
  export function finalizeDeck(dir, responses, claimToken, completedAt = new Date().toISOString()) {
172
175
  const ticket = requireRegisteredTicket(dir);
173
- return withTicketLock(ticket.dir, () => {
176
+ const finalized = withTicketLock(ticket.dir, () => {
174
177
  requireClaimOwnership(ticket.dir, claimToken);
175
178
  const deck = requireDeck(ticket.dir);
176
179
  const parsedResponses = validateDeckResponses(deck, responses);
177
180
  // A valid primary result ends its panel generation before the canonical
178
181
  // response can cross the owner boundary. The protocol persists cancellation
179
182
  // first; its independent cleanup executor owns eventual handler delivery.
180
- void cancelVisualRequestsForTicket(ticket.root, ticket.dir);
183
+ void cancelVisualRequestsForTicket(ticket.root, ticket.dir).finally(kickInboxMaintenance);
181
184
  const result = { schema: 'humanloop.response/v2', kind: 'deck', responses: parsedResponses, summary: buildSummary(deck, parsedResponses), completedAt };
182
185
  const won = exclusiveResult(ticket.dir, result);
183
186
  clearOwnedWork(ticket.dir, claimToken);
184
187
  return { won, result: won ? result : readTicketResult(ticket.dir) ?? result };
185
188
  });
189
+ if (finalized.won)
190
+ signalInboxActivity();
191
+ return finalized;
186
192
  }
187
193
  export function finalizeReview(dir, feedback, claimToken, completedAt = new Date().toISOString()) {
188
194
  const ticket = requireRegisteredTicket(dir);
189
- return withTicketLock(ticket.dir, () => {
195
+ const finalized = withTicketLock(ticket.dir, () => {
190
196
  requireClaimOwnership(ticket.dir, claimToken);
191
197
  const descriptor = requireReview(ticket.dir);
192
198
  const parsed = feedbackSchema.parse(feedback);
@@ -197,14 +203,20 @@ export function finalizeReview(dir, feedback, claimToken, completedAt = new Date
197
203
  clearOwnedWork(ticket.dir, claimToken);
198
204
  return { won, result: won ? result : readTicketResult(ticket.dir) ?? result, descriptor };
199
205
  });
206
+ if (finalized.won)
207
+ signalInboxActivity();
208
+ return finalized;
200
209
  }
201
210
  export function cancelTicketResult(dir, opts = {}) {
202
211
  const ticket = requireRegisteredTicket(dir);
203
- return withTicketLock(ticket.dir, () => {
212
+ const canceled = withTicketLock(ticket.dir, () => {
204
213
  const result = { schema: 'humanloop.cancel/v1', kind: 'canceled', canceledAt: new Date().toISOString(), ...(opts.reason === undefined ? {} : { reason: opts.reason }), ...(opts.actor === undefined ? {} : { actor: opts.actor }) };
205
214
  const won = exclusiveResult(ticket.dir, result);
206
215
  return { status: won ? 'canceled' : 'already_resolved', result: won ? result : readTicketResult(ticket.dir) ?? result };
207
216
  });
217
+ if (canceled.status === 'canceled')
218
+ signalInboxActivity();
219
+ return canceled;
208
220
  }
209
221
  export function ticketRoot(dir) {
210
222
  try {
package/dist/index.d.ts CHANGED
@@ -24,5 +24,5 @@ export { notifyDeck } from './inbox/deck-factories.js';
24
24
  export type { NotifyDeckOpts } from './inbox/deck-factories.js';
25
25
  export { deckPath, reviewPath, responsePath, progressPath, visualsDir, interactionState, isResolved, isClaimed, atomicWriteJson, readJson, writeResponse, writeProgress, clearProgress, } from './inbox/convention.js';
26
26
  export type { InteractionState } from './inbox/convention.js';
27
- export type { Interaction, InteractionOption, InteractionResponse, InteractionKind, Deck, DeckSource, CanonicalInteractionOption, CanonicalInteraction, MountedPanel, MountedPanelOpts, VisualProvider, VisualRequest, VisualHandle, VisualResult, VisualBlock, FollowUpState, FeedbackComment, FeedbackResult, ResolutionEnvelope, InboxItem, DisplayOpts, ClaimSummary, TicketSummary, DeckTicketSummary, ReviewTicketSummary, ReviewDescriptor, DeckTicketResult, ReviewTicketResult, CanceledTicketResult, TicketResult, CompletionEvent, } from './types.js';
27
+ export type { Interaction, InteractionOption, InteractionResponse, InteractionKind, Deck, DeckSource, CanonicalInteractionOption, CanonicalInteraction, MountedPanel, MountedPanelOpts, VisualProvider, VisualRequest, VisualHandle, VisualResult, VisualBlock, FollowUpState, FeedbackComment, FeedbackResult, ResolutionEnvelope, InboxItem, DisplayOpts, ClaimSummary, TicketSummary, DeckTicketSummary, ReviewTicketSummary, ReviewDescriptor, DeckTicketResult, ReviewTicketResult, CanceledTicketResult, TicketResult, CompletionEvent, FocusEvent, } from './types.js';
28
28
  export type { Key } from './tui/terminal.js';
@@ -1,2 +1,2 @@
1
1
  /** Run the inbox controller in a popup-owned TTY and accept graceful close requests. */
2
- export declare function openInboxPopup(controlSocket?: string, roots?: string[]): Promise<void>;
2
+ export declare function openInboxPopup(controlSocket?: string, roots?: string[], targetPane?: string): Promise<void>;
@@ -2,8 +2,8 @@ import { createServer } from 'node:net';
2
2
  import { rmSync } from 'node:fs';
3
3
  import { InboxController } from '../inbox/controller.js';
4
4
  /** Run the inbox controller in a popup-owned TTY and accept graceful close requests. */
5
- export async function openInboxPopup(controlSocket, roots) {
6
- const controller = new InboxController({ roots });
5
+ export async function openInboxPopup(controlSocket, roots, targetPane) {
6
+ const controller = new InboxController({ roots, targetPane });
7
7
  const server = controlSocket === undefined ? undefined : createServer((connection) => {
8
8
  connection.once('data', (data) => {
9
9
  if (data.toString('utf8').trim() === 'close')
package/dist/tui/tmux.js CHANGED
@@ -87,7 +87,8 @@ export async function toggleInboxPopup(target) {
87
87
  }
88
88
  if (existsSync(paths.controlSocket))
89
89
  rmSync(paths.controlSocket, { force: true });
90
- const command = `${quote(process.execPath)} ${quote(fileURLToPath(new URL('../cli.js', import.meta.url)))} inbox open --control-socket ${quote(paths.controlSocket)}`;
90
+ const targetPaneArg = resolved.targetPane === undefined ? '' : ` --target-pane ${quote(resolved.targetPane)}`;
91
+ const command = `${quote(process.execPath)} ${quote(fileURLToPath(new URL('../cli.js', import.meta.url)))} inbox open --control-socket ${quote(paths.controlSocket)}${targetPaneArg}`;
91
92
  const result = await launchPopup(socket, resolved, paths.controlSocket, command);
92
93
  logPopupEvent('toggle.completed', { ...resolved, controlSocket: paths.controlSocket, result });
93
94
  return result;
package/dist/types.d.ts CHANGED
@@ -247,6 +247,16 @@ export interface CompletionEvent {
247
247
  outcome: 'resolved' | 'canceled';
248
248
  responsePath: string;
249
249
  }
250
+ /** A popup request to reveal the host surface that created a ticket. The root's
251
+ * trusted focus handler decides what that means; humanloop only supplies the
252
+ * selected ticket and the tmux pane underneath the popup. */
253
+ export interface FocusEvent {
254
+ schema: 'humanloop.focus/v1';
255
+ root: string;
256
+ dir: string;
257
+ ticketId: string;
258
+ targetPane: string;
259
+ }
250
260
  /** Options for `display()` — the live-watch tmux pane surface. The pane always
251
261
  * watches the file and live-updates on edits; there is no non-watched mode. */
252
262
  export interface DisplayOpts {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/humanloop",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Human-in-the-loop decision TUI — agents write questions, humans answer them",
5
5
  "engines": {
6
6
  "node": ">=22.19.0"
@@ -44,6 +44,7 @@
44
44
  "devDependencies": {
45
45
  "@types/node": "^22.0.0",
46
46
  "@types/ws": "^8.18.1",
47
+ "esbuild": "^0.27.7",
47
48
  "tsx": "^4.0.0",
48
49
  "typescript": "^5.7.0"
49
50
  }