@crouton-kit/humanloop 0.3.33 → 0.3.35

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 +4 -8
  2. package/dist/api.js +6 -29
  3. package/dist/browser/server.d.ts +3 -3
  4. package/dist/browser/server.js +22 -25
  5. package/dist/cli.js +131 -1114
  6. package/dist/editor/feedback.d.ts +9 -0
  7. package/dist/editor/feedback.js +23 -0
  8. package/dist/editor/review.d.ts +12 -30
  9. package/dist/editor/review.js +109 -119
  10. package/dist/inbox/claim.d.ts +23 -0
  11. package/dist/inbox/claim.js +67 -0
  12. package/dist/inbox/completion.d.ts +4 -0
  13. package/dist/inbox/completion.js +107 -0
  14. package/dist/inbox/controller.d.ts +74 -0
  15. package/dist/inbox/controller.js +457 -0
  16. package/dist/inbox/convention.d.ts +15 -10
  17. package/dist/inbox/convention.js +149 -79
  18. package/dist/inbox/deck-adapter.d.ts +27 -0
  19. package/dist/inbox/deck-adapter.js +57 -0
  20. package/dist/inbox/deck-schema.d.ts +18 -14
  21. package/dist/inbox/deck-schema.js +59 -117
  22. package/dist/inbox/layout.d.ts +8 -0
  23. package/dist/inbox/layout.js +9 -0
  24. package/dist/inbox/registry.d.ts +29 -0
  25. package/dist/inbox/registry.js +97 -0
  26. package/dist/inbox/review-adapter.d.ts +24 -0
  27. package/dist/inbox/review-adapter.js +57 -0
  28. package/dist/inbox/scan.d.ts +3 -2
  29. package/dist/inbox/scan.js +71 -43
  30. package/dist/inbox/tickets.d.ts +63 -0
  31. package/dist/inbox/tickets.js +247 -0
  32. package/dist/inbox/tui.d.ts +3 -6
  33. package/dist/inbox/tui.js +24 -112
  34. package/dist/index.d.ts +12 -3
  35. package/dist/index.js +8 -2
  36. package/dist/render/termrender.d.ts +5 -0
  37. package/dist/render/termrender.js +63 -4
  38. package/dist/summary.d.ts +2 -3
  39. package/dist/summary.js +2 -3
  40. package/dist/surfaces/inbox-popup.d.ts +2 -0
  41. package/dist/surfaces/inbox-popup.js +32 -0
  42. package/dist/tui/app.js +13 -0
  43. package/dist/tui/tmux.d.ts +30 -7
  44. package/dist/tui/tmux.js +190 -66
  45. package/dist/types.d.ts +68 -7
  46. package/package.json +2 -2
package/dist/tui/tmux.js CHANGED
@@ -1,74 +1,198 @@
1
- import { execFileSync } from 'child_process';
2
- import { existsSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync } from 'fs';
3
- import { tmpdir } from 'os';
4
- import { join } from 'path';
5
- function shellQuote(s) {
6
- if (s.length > 0 && /^[a-zA-Z0-9_\-./:@%+=]+$/.test(s))
7
- return s;
8
- return "'" + s.replace(/'/g, `'\\''`) + "'";
9
- }
10
- function buildChildCmd(file, resultPath, opts) {
11
- const scriptPath = process.argv[1];
12
- if (!scriptPath) {
13
- throw new Error('Cannot determine hl script path from process.argv[1]');
1
+ import { createHash } from 'node:crypto';
2
+ import { execFileSync, spawn } from 'node:child_process';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { homedir, tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ const DEFAULT_KEY = 'M-i';
8
+ const POPUP_TITLE = 'humanloop · inbox';
9
+ const POPUP_STYLE = 'bg=#20242d';
10
+ const POPUP_BORDER_STYLE = 'fg=#5c6370';
11
+ function runtimeDirectory() {
12
+ const base = process.env['XDG_RUNTIME_DIR'] || process.env['TMPDIR'] || tmpdir();
13
+ return join(base, `humanloop-${process.getuid?.() ?? process.env['UID'] ?? 'user'}`);
14
+ }
15
+ export function popupPaths(target) {
16
+ const identity = createHash('sha256').update(`${target.socket}\0${target.client}`).digest('hex').slice(0, 16);
17
+ const base = join(runtimeDirectory(), 'inbox');
18
+ return { controlSocket: join(base, `${identity}.sock`), startupLock: join(base, `${identity}.lock`) };
19
+ }
20
+ function tmux(socket, args) {
21
+ return execFileSync('tmux', ['-S', socket, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
22
+ }
23
+ function quote(value) { return `'${value.replace(/'/g, `'\\''`)}'`; }
24
+ function bindingCommand() {
25
+ // --quiet keeps the background `run-shell -b` binding from printing its result JSON, which
26
+ // tmux would otherwise surface as a view-mode overlay on the active pane. The popup opening
27
+ // is the feedback; a genuine failure still prints (see the CLI toggle action).
28
+ return 'hl inbox toggle --quiet --tmux-socket "#{socket_path}" --tmux-client "#{client_name}" --target-pane "#{pane_id}"';
29
+ }
30
+ function configuredKeyPath() {
31
+ const state = process.env['XDG_STATE_HOME'] || join(homedir(), '.local', 'state');
32
+ return join(state, 'humanloop', 'inbox-key');
33
+ }
34
+ function configuredKey() {
35
+ try {
36
+ return readFileSync(configuredKeyPath(), 'utf8').trim() || DEFAULT_KEY;
37
+ }
38
+ catch {
39
+ return DEFAULT_KEY;
14
40
  }
15
- const parts = [
16
- shellQuote(process.execPath),
17
- shellQuote(scriptPath),
18
- 'ask',
19
- shellQuote(file),
20
- '--dir',
21
- shellQuote(opts.dir),
22
- '--write-to',
23
- shellQuote(resultPath),
24
- ];
25
- if (opts.sessionId) {
26
- parts.push('--session-id', shellQuote(opts.sessionId));
41
+ }
42
+ function writeConfiguredKey(key) {
43
+ mkdirSync(join(configuredKeyPath(), '..'), { recursive: true, mode: 0o700 });
44
+ writeFileSync(configuredKeyPath(), `${key}\n`, { mode: 0o600 });
45
+ }
46
+ function rootBinding(socket, key) {
47
+ try {
48
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
49
+ const match = tmux(socket, ['list-keys', '-T', 'root']).split('\n').map((row) => new RegExp(`^bind-key\\s+-T root\\s+${escaped}\\s+(.*)$`).exec(row)).find((entry) => entry !== null);
50
+ return match?.[1];
27
51
  }
28
- if (!opts.visuals) {
29
- parts.push('--no-visuals');
52
+ catch {
53
+ return undefined;
30
54
  }
31
- return parts.join(' ');
32
- }
33
- export async function dispatchToTmuxPane(file, opts) {
34
- const parentTmp = mkdtempSync(join(tmpdir(), 'hl-'));
35
- const resultPath = join(parentTmp, 'result.json');
36
- const cmd = buildChildCmd(file, resultPath, opts);
37
- // Capture the spawned pane id so we can detect if the user closes it
38
- // without finishing — otherwise the parent would poll forever.
39
- const paneId = execFileSync('tmux', ['split-window', '-P', '-F', '#{pane_id}', '-h', '-d', cmd], { encoding: 'utf8' }).trim();
40
- await new Promise((resolve, reject) => {
41
- const poll = setInterval(() => {
42
- if (existsSync(resultPath)) {
43
- clearInterval(poll);
44
- resolve();
45
- return;
46
- }
47
- // Check the pane is still alive. If it's gone and there's still no
48
- // result file, the child died (closed pane, crash, etc).
49
- try {
50
- const panes = execFileSync('tmux', ['list-panes', '-a', '-F', '#{pane_id}'], {
51
- encoding: 'utf8',
52
- });
53
- if (!panes.split('\n').map((s) => s.trim()).includes(paneId)) {
54
- clearInterval(poll);
55
- reject(new Error(`tmux pane ${paneId} closed before writing a result`));
56
- }
57
- }
58
- catch (err) {
59
- clearInterval(poll);
60
- reject(new Error(`tmux list-panes failed: ${err instanceof Error ? err.message : String(err)}`));
61
- }
62
- }, 150);
63
- });
64
- const json = readFileSync(resultPath, 'utf8');
55
+ }
56
+ function isCanonical(command) {
57
+ const normalized = command?.replace(/\\"/g, '"');
58
+ return normalized !== undefined && normalized.includes('hl inbox toggle') && normalized.includes('--tmux-socket "#{socket_path}"') && normalized.includes('--tmux-client "#{client_name}"') && normalized.includes('--target-pane "#{pane_id}"');
59
+ }
60
+ export function inspectInboxBinding(socket = tmuxSocketFromEnvironment()) {
61
+ const key = configuredKey();
62
+ if (socket === undefined)
63
+ return { state: 'unbound', key, isDefault: key === DEFAULT_KEY };
64
+ const command = rootBinding(socket, key);
65
+ return { state: command === undefined ? 'unbound' : isCanonical(command) ? 'installed' : 'collision', key, isDefault: key === DEFAULT_KEY };
66
+ }
67
+ export function installInboxBinding(opts = {}) {
68
+ const socket = opts.socket ?? tmuxSocketFromEnvironment();
69
+ const key = opts.key ?? configuredKey();
70
+ if (socket === undefined)
71
+ return { state: 'unbound', key, isDefault: key === DEFAULT_KEY };
72
+ const existing = rootBinding(socket, key);
73
+ if (existing !== undefined && !isCanonical(existing))
74
+ return { state: 'collision', key, isDefault: key === DEFAULT_KEY };
75
+ if (existing === undefined)
76
+ tmux(socket, ['bind-key', '-T', 'root', key, 'run-shell', '-b', bindingCommand()]);
77
+ if (opts.key !== undefined) {
78
+ // Switching to a new key: drop the previous configured key iff it still holds the
79
+ // canonical toggle, so bindings don't accrete and inspect/unbind track a single live key.
80
+ const previous = configuredKey();
81
+ if (previous !== key && isCanonical(rootBinding(socket, previous)))
82
+ tmux(socket, ['unbind-key', '-T', 'root', previous]);
83
+ writeConfiguredKey(key);
84
+ }
85
+ return { state: 'installed', key, isDefault: key === DEFAULT_KEY };
86
+ }
87
+ export function unbindInboxBinding(socket = tmuxSocketFromEnvironment()) {
88
+ const key = configuredKey();
89
+ if (socket !== undefined && isCanonical(rootBinding(socket, key)))
90
+ tmux(socket, ['unbind-key', '-T', 'root', key]);
91
+ return inspectInboxBinding(socket);
92
+ }
93
+ export function tmuxSocketFromEnvironment() {
94
+ const value = process.env['TMUX'];
95
+ return value?.split(',')[0] || undefined;
96
+ }
97
+ export function inferTmuxClient(socket, pane = process.env['TMUX_PANE']) {
98
+ if (pane === undefined)
99
+ return undefined;
100
+ const matches = tmux(socket, ['list-clients', '-F', '#{client_name}\t#{pane_id}']).split('\n')
101
+ .map((line) => line.split('\t')).filter((parts) => parts[1] === pane).map((parts) => parts[0]);
102
+ return matches.length === 1 ? matches[0] : matches.length > 1 ? 'ambiguous' : undefined;
103
+ }
104
+ function acquireStartupLock(path) {
65
105
  try {
66
- unlinkSync(resultPath);
106
+ mkdirSync(path, { mode: 0o700 });
107
+ return true;
108
+ }
109
+ catch {
110
+ return false;
67
111
  }
68
- catch { /* ignore */ }
112
+ }
113
+ export async function toggleInboxPopup(target) {
114
+ const socket = target?.socket ?? tmuxSocketFromEnvironment();
115
+ if (socket === undefined)
116
+ return 'not_in_tmux';
117
+ const inferred = target?.client ?? inferTmuxClient(socket, target?.targetPane);
118
+ if (inferred === 'ambiguous')
119
+ return 'ambiguous_client';
120
+ if (inferred === undefined)
121
+ return 'ambiguous_client';
122
+ const resolved = { socket, client: inferred, targetPane: target?.targetPane };
123
+ const paths = popupPaths(resolved);
124
+ mkdirSync(join(paths.controlSocket, '..'), { recursive: true, mode: 0o700 });
125
+ if (await requestPopupClose(paths.controlSocket))
126
+ return 'closed';
127
+ // A concurrent toggle for this same client won the startup lock and is opening the popup.
128
+ // This gesture must not launch a second one; report a benign no-op close rather than a
129
+ // generic failure, so exactly one popup exists and the loser never exits with an error.
130
+ if (!acquireStartupLock(paths.startupLock))
131
+ return 'closed';
69
132
  try {
70
- rmdirSync(parentTmp);
133
+ // Re-probe under the lock: between the first probe and acquiring the lock a concurrent
134
+ // toggle may have brought a live popup up. Deleting its control socket now would orphan
135
+ // that popup, so close it instead. The finally below releases the lock on every path.
136
+ if (await requestPopupClose(paths.controlSocket))
137
+ return 'closed';
138
+ if (existsSync(paths.controlSocket))
139
+ rmSync(paths.controlSocket, { force: true });
140
+ const command = `${quote(process.execPath)} ${quote(fileURLToPath(new URL('../cli.js', import.meta.url)))} inbox open --control-socket ${quote(paths.controlSocket)}`;
141
+ return await launchPopup(socket, resolved, paths.controlSocket, command);
71
142
  }
72
- catch { /* ignore */ }
73
- return JSON.parse(json);
143
+ finally {
144
+ rmSync(paths.startupLock, { recursive: true, force: true });
145
+ }
146
+ }
147
+ /** Connect to the control socket and, if a live popup owns it, ask it to close. Resolves true when a popup answered. */
148
+ async function requestPopupClose(controlSocket) {
149
+ const { Socket } = await import('node:net');
150
+ return new Promise((resolve) => {
151
+ const client = new Socket();
152
+ const done = (value) => { client.destroy(); resolve(value); };
153
+ client.setTimeout(300, () => done(false));
154
+ client.once('error', () => done(false));
155
+ client.connect(controlSocket, () => { client.end('close\n'); done(true); });
156
+ });
157
+ }
158
+ /** Launch one popup and report `opened` only once its controller owns the control socket. */
159
+ async function launchPopup(socket, target, controlSocket, command) {
160
+ const args = ['-S', socket, 'display-popup', '-E', '-c', target.client, ...inboxPopupFlags(), ...(target.targetPane === undefined ? [] : ['-t', target.targetPane]), command];
161
+ const child = spawn('tmux', args, { stdio: ['ignore', 'ignore', 'pipe'], detached: true });
162
+ let stderr = '';
163
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); });
164
+ const exited = new Promise((resolve) => {
165
+ // `display-popup -E` blocks until our popup closes, so a successful open keeps the child
166
+ // alive while the control socket comes up (detected below). An early exit before that means
167
+ // the popup command never ran: tmux 3.x silently declines to stack a popup on a client that
168
+ // already shows one (exit 0, command dropped), which is exactly the foreign-popup case the
169
+ // design must report as `other_popup` without disturbing the existing popup or its process.
170
+ child.once('exit', (code) => resolve(code === 0 || /popup/i.test(stderr) ? 'other_popup' : 'failed'));
171
+ child.once('error', () => resolve('failed'));
172
+ });
173
+ const { Socket } = await import('node:net');
174
+ for (let attempt = 0; attempt < 50; attempt++) {
175
+ const outcome = await Promise.race([
176
+ exited,
177
+ new Promise((resolve) => {
178
+ const probe = new Socket();
179
+ const done = (value) => { probe.destroy(); setTimeout(() => resolve(value), value === 'pending' ? 100 : 0); };
180
+ probe.setTimeout(200, () => done('pending'));
181
+ probe.once('error', () => done('pending'));
182
+ probe.connect(controlSocket, () => done('opened'));
183
+ }),
184
+ ]);
185
+ if (outcome === 'opened') {
186
+ child.unref();
187
+ return 'opened';
188
+ }
189
+ if (outcome !== 'pending')
190
+ return outcome;
191
+ }
192
+ return 'failed';
193
+ }
194
+ /** The static tmux `display-popup` geometry and style flags; the client and target pane are added per-invocation. */
195
+ export function inboxPopupFlags() {
196
+ return ['-w', '90%', '-h', '90%', '-b', 'rounded', '-T', POPUP_TITLE, '-s', POPUP_STYLE, '-S', POPUP_BORDER_STYLE];
74
197
  }
198
+ export const inboxPopupStyle = { width: '90%', height: '90%', border: 'rounded', title: POPUP_TITLE, background: '#20242d', chrome: '#2b3245', borderColor: '#5c6370' };
package/dist/types.d.ts CHANGED
@@ -137,8 +137,7 @@ export interface TuiState {
137
137
  }
138
138
  /**
139
139
  * Resolution contract returned by `ask`/`inbox`. On-disk `response.json` stays
140
- * `{ responses, completedAt }`; `responsePath` points at it. `hl schema
141
- * response` returns the JSON Schema this `schema` id names.
140
+ * `{ responses, completedAt }`; `responsePath` points at it.
142
141
  */
143
142
  export interface ResolutionEnvelope {
144
143
  /** 1 line/interaction "<title>: <option label>[ — <freetext>]"; deterministic, no LLM. */
@@ -155,15 +154,76 @@ export interface ResolutionEnvelope {
155
154
  * One pending interaction discovered by `scanInbox`. Read from the
156
155
  * `deck.json` header only — never the full deck.
157
156
  */
158
- export interface InboxItem {
157
+ export interface ClaimSummary {
158
+ owner: string;
159
+ claimedAt: string;
160
+ heartbeatAt: string;
161
+ }
162
+ interface TicketSummaryBase {
159
163
  dir: string;
160
164
  id: string;
161
- title?: string;
165
+ title: string;
162
166
  subtitle?: string;
163
- kind?: InteractionKind;
164
- /** `deck.source.blockedSince` ?? `statSync(deck.json).mtime` (ISO). */
165
167
  blockedSince: string;
166
- source?: DeckSource;
168
+ source: DeckSource;
169
+ claim?: ClaimSummary;
170
+ }
171
+ export interface DeckTicketSummary extends TicketSummaryBase {
172
+ kind: 'deck';
173
+ interactionKind?: InteractionKind;
174
+ }
175
+ export interface ReviewTicketSummary extends TicketSummaryBase {
176
+ kind: 'review';
177
+ file: string;
178
+ output: string;
179
+ }
180
+ /** The only pending-ticket shape scanners expose. */
181
+ export type TicketSummary = DeckTicketSummary | ReviewTicketSummary;
182
+ /** Compatibility name retained only while the list adapter is moved in H2. */
183
+ export type InboxItem = TicketSummary;
184
+ export interface ReviewDescriptor {
185
+ schema: 'humanloop.review/v1';
186
+ file: string;
187
+ output: string;
188
+ title: string;
189
+ source: DeckSource;
190
+ blockedSince: string;
191
+ }
192
+ export interface DeckTicketResult {
193
+ schema: 'humanloop.response/v2';
194
+ kind: 'deck';
195
+ responses: InteractionResponse[];
196
+ summary: string;
197
+ completedAt: string;
198
+ }
199
+ export interface ReviewTicketResult {
200
+ schema: 'humanloop.review-response/v1';
201
+ kind: 'review';
202
+ result: FeedbackResult;
203
+ completedAt: string;
204
+ }
205
+ export interface CanceledTicketResult {
206
+ schema: 'humanloop.cancel/v1';
207
+ kind: 'canceled';
208
+ canceledAt: string;
209
+ reason?: string;
210
+ actor?: string;
211
+ }
212
+ /** The sole canonical response.json union. */
213
+ export type TicketResult = DeckTicketResult | ReviewTicketResult | CanceledTicketResult;
214
+ export interface InboxBindingState {
215
+ state: 'installed' | 'collision' | 'unbound';
216
+ key: string;
217
+ isDefault: boolean;
218
+ }
219
+ export interface CompletionEvent {
220
+ schema: 'humanloop.completion/v1';
221
+ root: string;
222
+ dir: string;
223
+ ticketId: string;
224
+ kind: 'deck' | 'review' | 'canceled';
225
+ outcome: 'resolved' | 'canceled';
226
+ responsePath: string;
167
227
  }
168
228
  /** Options for `display()` — the live-watch tmux pane surface. The pane always
169
229
  * watches the file and live-updates on edits; there is no non-watched mode. */
@@ -223,3 +283,4 @@ export interface MountedPanel {
223
283
  */
224
284
  setInputBuffer(text: string): void;
225
285
  }
286
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/humanloop",
3
- "version": "0.3.33",
3
+ "version": "0.3.35",
4
4
  "description": "Human-in-the-loop decision TUI — agents write questions, humans answer them",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,7 +29,7 @@
29
29
  "dev": "tsx src/cli.ts",
30
30
  "link": "npm link",
31
31
  "postinstall": "node dist/scripts/install-renderer.js || true",
32
- "test": "tsx src/__tests__/mount-panel.test.ts && tsx src/__tests__/feedback.test.ts && tsx src/__tests__/browser-server.test.ts && tsx web/src/__tests__/review-sourcemap.test.ts && tsx web/src/__tests__/review-reducer.test.ts && tsx web/src/__tests__/review-keymap.test.ts && tsx web/src/__tests__/review-markdown-instrumentation.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/app-deck-regression.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/review-surface-conflict.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/app-ws-close.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/review-surface-takeback.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/review-surface-submit-race.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/review-surface-stale-submit-failure.test.ts"
32
+ "test": "tsx src/__tests__/inbox-core.test.ts && tsx src/__tests__/inbox-controller.test.ts && tsx src/__tests__/review-adapter.test.ts && tsx src/__tests__/mount-panel.test.ts && tsx src/__tests__/feedback.test.ts && tsx src/__tests__/browser-server.test.ts && tsx web/src/__tests__/review-sourcemap.test.ts && tsx web/src/__tests__/review-reducer.test.ts && tsx web/src/__tests__/review-keymap.test.ts && tsx web/src/__tests__/review-markdown-instrumentation.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/app-deck-regression.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/review-surface-conflict.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/app-ws-close.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/review-surface-takeback.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/review-surface-submit-race.test.ts && tsx --tsconfig web/tsconfig.json web/src/__tests__/review-surface-stale-submit-failure.test.ts && tsx src/__tests__/inbox-popup.test.ts"
33
33
  },
34
34
  "dependencies": {
35
35
  "@r-cli/sdk": "^1.3.0",