@hwip/cli 0.1.0 → 0.1.2

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.
@@ -1,15 +1,20 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.promptPlaintext = exports.parseBlockedDialog = exports.isPromptId = exports.MAX_PROMPT_BYTES = exports.MAX_OPTIONS = exports.MAX_LABEL_CHARS = exports.MAX_DETAIL_LINE_CHARS = exports.MAX_DETAIL_LINES = exports.KNOWN_TITLES = void 0;
3
+ exports.promptPlaintext = exports.showsPrompt = exports.parseBlockedDialog = exports.stripEscapes = exports.isPromptId = exports.MAX_PROMPT_BYTES = exports.MAX_OPTIONS = exports.MAX_LABEL_CHARS = exports.MAX_DETAIL_LINE_CHARS = exports.MAX_DETAIL_LINES = exports.KNOWN_TITLES = void 0;
4
4
  /**
5
- * What a blocked Claude Code session is waiting for, read off its screen.
5
+ * What a blocked agent session is waiting for, read off its screen.
6
6
  *
7
7
  * The state detector (`agent-state.ts`) only says "blocked". The app wants to
8
8
  * draw the question: which tool, which command, which answers. This module
9
- * reads the approval dialog at the bottom of the pane into that summary. Pure:
10
- * the listener owns tmux, sealing and timing (`blocked-prompt-seal.ts`).
9
+ * reads the approval dialog at the bottom of the pane into that summary, with a
10
+ * reader per agent kind (`parseBlockedDialog`). Pure: the listener owns tmux,
11
+ * sealing and timing (`blocked-prompt-seal.ts`).
11
12
  *
12
- * Layout read (Claude Code, captured from a live session; see the fixtures in
13
+ * The pane may carry terminal escapes (`tmux capture-pane -e`): OpenCode marks its
14
+ * chosen button with a background colour only. Every other reader works on the
15
+ * text with them stripped, so its ids are those of a plain capture.
16
+ *
17
+ * Claude Code layout (captured from a live session; see the fixtures in
13
18
  * `blocked-prompt.test.ts`):
14
19
  *
15
20
  * ───────────── full-width rule: the dialog starts below the last one
@@ -30,6 +35,17 @@ exports.promptPlaintext = exports.parseBlockedDialog = exports.isPromptId = expo
30
35
  * Only the titles in `KNOWN_TITLES` are read. Another dialog (a plan, a
31
36
  * question to the user, an MCP tool) has no captured layout, so it yields
32
37
  * nothing and the session keeps reporting the bare `blocked` state.
38
+ *
39
+ * Gemini CLI layout (v0.46.0, `blocked-prompt-gemini.test.ts`): one rounded box,
40
+ * a `? <Tool> <summary>` header (a right-aligned `N of M` when approvals queue),
41
+ * the command or diff in a nested box, the question, then `●`-highlighted
42
+ * numbered answers. No footer.
43
+ *
44
+ * pi has no approval dialog of its own; extensions that gate tools ask through
45
+ * `ctx.ui.select` (`parsePiSelect`, `blocked-prompt-pi.test.ts`).
46
+ *
47
+ * OpenCode (v2.0.29, `blocked-prompt-opencode.test.ts`): a `┃` panel, `△ Permission required`, the request, and a
48
+ * row of buttons whose chosen one is told apart by background colour only (`parseOpencodePermission`).
33
49
  */
34
50
  const node_crypto_1 = require("node:crypto");
35
51
  /** Dialog titles whose layout was captured and is read. */
@@ -63,8 +79,8 @@ const cut = (text, max) => {
63
79
  const idOf = (region) => (0, node_crypto_1.createHash)('sha256').update(region.join('\n')).digest('hex').slice(0, 16);
64
80
  /** `down`×k or `up`×k to reach option `to` from option `from`, then `enter`. */
65
81
  const keysFor = (from, to) => [...Array(Math.abs(to - from)).fill(to > from ? 'down' : 'up'), 'enter'];
66
- /** The dialog at the bottom of `pane`, or undefined when it is not one this module reads. */
67
- const parseBlockedDialog = (pane) => {
82
+ /** Claude Code's dialog at the bottom of `pane`, or undefined when it is not one this module reads. */
83
+ const parseClaudeDialog = (pane) => {
68
84
  const lines = pane.split('\n').map((l) => l.trimEnd());
69
85
  // The question is the last "…?" line whose next non-blank line is option 1.
70
86
  let qAt = -1;
@@ -118,13 +134,6 @@ const parseBlockedDialog = (pane) => {
118
134
  return undefined;
119
135
  }
120
136
  }
121
- if (options.length < 2 || options.length > exports.MAX_OPTIONS)
122
- return undefined;
123
- if (!options.every((o, i) => o.n === i + 1))
124
- return undefined;
125
- const selected = options.findIndex((o) => o.selected);
126
- if (selected < 0 || options.filter((o) => o.selected).length !== 1)
127
- return undefined;
128
137
  // Above the question: title, subtitle, dashed rule, detail, dashed rule, note.
129
138
  const head = region.slice(0, q);
130
139
  const title = head.find((l) => l.trim() !== '')?.trim();
@@ -139,28 +148,356 @@ const parseBlockedDialog = (pane) => {
139
148
  while (detailLines.length > 0 && detailLines[detailLines.length - 1].trim() === '')
140
149
  detailLines.pop();
141
150
  const after = secondDash < 0 ? [] : rest.slice(secondDash + 1).map((l) => l.trim()).filter(Boolean);
142
- const prompt = {
143
- id: idOf(region),
151
+ return assemble({
152
+ region,
144
153
  title,
145
- detail: detailLines.slice(0, exports.MAX_DETAIL_LINES).map((l) => cut(l, exports.MAX_DETAIL_LINE_CHARS)),
146
- question: cut(region[q].trim(), exports.MAX_LABEL_CHARS),
154
+ subtitle: before.join(' '),
155
+ detail: detailLines,
156
+ note: after.join(' '),
157
+ question: region[q].trim(),
158
+ options,
159
+ });
160
+ };
161
+ // CSI sequences (colours and the rest) and OSC ones (OSC 8 hyperlinks, ended by BEL or ST): what `-e` adds.
162
+ const ESCAPES = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
163
+ /** `pane` without terminal escapes: what `capture-pane` without `-e` gives. */
164
+ const stripEscapes = (pane) => pane.replace(ESCAPES, '');
165
+ exports.stripEscapes = stripEscapes;
166
+ /** The inside of a line drawn as `│ … │`, without its one-space padding; undefined for any other line. */
167
+ const boxed = (line) => {
168
+ const t = line.trim();
169
+ if (t.length < 2 || !t.startsWith('│') || !t.endsWith('│'))
170
+ return undefined;
171
+ return t.slice(1, -1).replace(/^ /, '').trimEnd();
172
+ };
173
+ /** `lines` without blank lines at either end. */
174
+ const trimBlank = (lines) => {
175
+ let from = 0;
176
+ let to = lines.length;
177
+ while (from < to && lines[from].trim() === '')
178
+ from++;
179
+ while (to > from && lines[to - 1].trim() === '')
180
+ to--;
181
+ return lines.slice(from, to);
182
+ };
183
+ /** `lines` without the indent they all share. */
184
+ const dedent = (lines) => {
185
+ const indent = Math.min(...lines.filter((l) => l.trim() !== '').map((l) => l.length - l.trimStart().length));
186
+ return Number.isFinite(indent) ? lines.map((l) => l.slice(indent)) : [...lines];
187
+ };
188
+ const GEMINI_OPTION = /^([●\s])\s*(\d{1,2})\.\s+(\S.*)$/;
189
+ const GEMINI_HEADER = /^\?\s+(\S.*)$/;
190
+ const GEMINI_QUEUE = /\s{2,}\d+ of \d+$/;
191
+ /** Gemini CLI's tool approval box, or undefined. */
192
+ const parseGeminiDialog = (pane) => {
193
+ const lines = pane.split('\n').map((l) => l.trimEnd());
194
+ // The question is the last boxed "…?" line whose next non-blank boxed line is option 1.
195
+ let qAt = -1;
196
+ for (let i = lines.length - 2; i >= 0 && qAt < 0; i--) {
197
+ const q = boxed(lines[i]);
198
+ if (q === undefined || !q.endsWith('?') || q.startsWith('│'))
199
+ continue;
200
+ for (let j = i + 1; j < lines.length; j++) {
201
+ const next = boxed(lines[j]);
202
+ if (next === undefined)
203
+ break;
204
+ if (next === '')
205
+ continue;
206
+ if (GEMINI_OPTION.exec(next)?.[2] === '1')
207
+ qAt = i;
208
+ break;
209
+ }
210
+ }
211
+ if (qAt < 0)
212
+ return undefined;
213
+ // Up to the box's top border, down to its bottom one.
214
+ let top = qAt - 1;
215
+ while (top >= 0 && boxed(lines[top]) !== undefined)
216
+ top--;
217
+ if (top < 0 || !lines[top].trim().startsWith('╭'))
218
+ return undefined;
219
+ let end = qAt + 1;
220
+ while (end < lines.length && boxed(lines[end]) !== undefined)
221
+ end++;
222
+ if (end >= lines.length || !lines[end].trim().startsWith('╰'))
223
+ return undefined;
224
+ // Gemini hides its input box while it asks: only blank lines and other boxes (a notice) may follow. The same box
225
+ // drawn in the agent's own text sits above the input box, and is not a dialog.
226
+ for (const line of lines.slice(end + 1)) {
227
+ const t = line.trim();
228
+ if (t !== '' && !t.startsWith('╭') && !t.startsWith('╰') && boxed(line) === undefined)
229
+ return undefined;
230
+ }
231
+ const header = GEMINI_HEADER.exec(boxed(lines[top + 1]).replace(GEMINI_QUEUE, ''));
232
+ if (!header)
233
+ return undefined;
234
+ const [title, ...rest] = header[1].split(/\s{2,}/);
235
+ const subtitle = rest.join(' ').trim();
236
+ const options = [];
237
+ for (const line of lines.slice(qAt + 1, end)) {
238
+ const text = boxed(line);
239
+ if (text === '')
240
+ continue;
241
+ const m = GEMINI_OPTION.exec(text);
242
+ if (m) {
243
+ options.push({ n: Number(m[2]), label: m[3].trim(), selected: m[1] === '●' });
244
+ }
245
+ else if (options.length > 0 && /^\s{4,}\S/.test(text)) {
246
+ const last = options[options.length - 1];
247
+ last.label = `${last.label} ${text.trim()}`;
248
+ }
249
+ else {
250
+ return undefined;
251
+ }
252
+ }
253
+ // Between header and question: the nested box holds the command or diff; any other text is a note.
254
+ const detailLines = [];
255
+ const noteLines = [];
256
+ for (const line of lines.slice(top + 2, qAt)) {
257
+ const text = boxed(line);
258
+ if (text.startsWith('╭') || text.startsWith('╰'))
259
+ continue;
260
+ const inner = boxed(text);
261
+ if (inner !== undefined)
262
+ detailLines.push(inner);
263
+ else if (text.trim() !== '')
264
+ noteLines.push(text.trim());
265
+ }
266
+ const detail = dedent(trimBlank(detailLines));
267
+ return assemble({
268
+ region: lines.slice(top, end + 1),
269
+ title: title.trim(),
270
+ subtitle,
271
+ detail,
272
+ note: noteLines.join(' '),
273
+ question: boxed(lines[qAt]).trim(),
274
+ options,
275
+ });
276
+ };
277
+ const PI_FOOTER = /^ ↑↓ navigate {2}\S.* select {2}\S.* cancel$/;
278
+ const PI_OPTION = /^ (→| ) (\S.*)$/;
279
+ const PI_COUNTDOWN = / \(\d+s\)$/;
280
+ /**
281
+ * pi's `ctx.ui.select` dialog (`ExtensionSelectorComponent`), which approval extensions draw: a free title between
282
+ * full-width rules, unnumbered answers with `→` on the highlighted one, a `↑↓ navigate … select … cancel` footer.
283
+ * Title, question and detail come from the title's lines: first, last, and what is between.
284
+ */
285
+ const parsePiSelect = (pane) => {
286
+ const lines = pane.split('\n').map((l) => l.trimEnd());
287
+ let footer = -1;
288
+ for (let i = lines.length - 1; i >= 0 && footer < 0; i--)
289
+ if (PI_FOOTER.test(lines[i]))
290
+ footer = i;
291
+ if (footer < 0)
292
+ return undefined;
293
+ let top = -1;
294
+ for (let i = footer - 1; i >= 0 && top < 0; i--)
295
+ if (FULL_RULE.test(lines[i]))
296
+ top = i;
297
+ if (top < 0)
298
+ return undefined;
299
+ // The select takes the editor's place, so one rule follows it. The same text in the agent's own output sits
300
+ // above the editor, which brings its two rules.
301
+ if (lines.slice(footer + 1).filter((l) => FULL_RULE.test(l)).length > 1)
302
+ return undefined;
303
+ // The answers sit right above the footer's gap, with a gap above them too.
304
+ let last = footer - 1;
305
+ while (last > top && lines[last] === '')
306
+ last--;
307
+ let first = last;
308
+ while (first - 1 > top && PI_OPTION.test(lines[first - 1]))
309
+ first--;
310
+ if (first - 1 <= top || lines[first - 1] !== '')
311
+ return undefined;
312
+ const options = [];
313
+ for (const line of lines.slice(first, last + 1)) {
314
+ const m = PI_OPTION.exec(line);
315
+ if (!m)
316
+ return undefined;
317
+ options.push({ n: options.length + 1, label: m[2].trim(), selected: m[1] === '→' });
318
+ }
319
+ // A select with a timeout redraws its title's end as ` (12s)` every second: not part of the prompt or its id.
320
+ for (let i = first - 1; i > top; i--) {
321
+ if (lines[i] === '')
322
+ continue;
323
+ lines[i] = lines[i].replace(PI_COUNTDOWN, '');
324
+ break;
325
+ }
326
+ const text = trimBlank(lines.slice(top + 1, first).map((l) => l.replace(/^ /, '')));
327
+ if (text.length === 0)
328
+ return undefined;
329
+ return assemble({
330
+ region: lines.slice(top, footer + 1),
331
+ title: text[0].trim(),
332
+ detail: dedent(trimBlank(text.slice(1, -1))),
333
+ question: text[text.length - 1].trim(),
334
+ options,
335
+ });
336
+ };
337
+ // One SGR sequence and its parameters; any other escape is dropped without changing the background.
338
+ const SGR = /^\u001b\[([0-9;:]*)m/;
339
+ const ANY_ESCAPE = new RegExp(`^(?:${ESCAPES.source})`);
340
+ /** `line` without escapes, and the background each of its UTF-16 units is drawn on ('7' = reverse video). */
341
+ const withBackground = (line) => {
342
+ let text = '';
343
+ const bg = [];
344
+ let current = '';
345
+ let reverse = false;
346
+ for (let i = 0; i < line.length;) {
347
+ const sgr = SGR.exec(line.slice(i));
348
+ if (sgr) {
349
+ const p = sgr[1] === '' ? ['0'] : sgr[1].split(/[;:]/);
350
+ for (let j = 0; j < p.length; j++) {
351
+ const code = Number(p[j]);
352
+ if (code === 0) {
353
+ current = '';
354
+ reverse = false;
355
+ }
356
+ else if (code === 7)
357
+ reverse = true;
358
+ else if (code === 27)
359
+ reverse = false;
360
+ else if (code === 49)
361
+ current = '';
362
+ else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107))
363
+ current = String(code);
364
+ else if (code === 38 || code === 48 || code === 58) {
365
+ const span = p[j + 1] === '2' ? 3 : p[j + 1] === '5' ? 1 : 0;
366
+ if (code === 48)
367
+ current = p.slice(j, j + 2 + span).join(';');
368
+ j += 1 + span;
369
+ }
370
+ }
371
+ i += sgr[0].length;
372
+ continue;
373
+ }
374
+ const other = ANY_ESCAPE.exec(line.slice(i));
375
+ if (other) {
376
+ i += other[0].length;
377
+ continue;
378
+ }
379
+ text += line[i];
380
+ bg.push(reverse ? '7' : current);
381
+ i++;
382
+ }
383
+ return { text, bg };
384
+ };
385
+ const OPENCODE_BAR = '┃';
386
+ const OPENCODE_HEADER = /^\s*△ Permission required$/;
387
+ const OPENCODE_HINTS = /ctrl\+|⇆|enter confirm/;
388
+ const OPENCODE_SUBTITLE = /^→ (\S.*)$/;
389
+ /**
390
+ * OpenCode's permission request: `△ Permission required` in a `┃` panel, the request (a `$ command`, or `→ Edit file`
391
+ * over its diff), then a row of buttons `Allow once Always allow Reject` beside `… enter confirm`. The chosen
392
+ * button differs from the others only by its background, so this reads the pane with its escapes: a plain capture
393
+ * yields nothing. Left/right move the choice; the dialog draws no question, so the question is a fixed one.
394
+ */
395
+ const parseOpencodePermission = (pane) => {
396
+ const rows = pane.split('\n').map(withBackground);
397
+ // Text after the panel bar, with the bar's column, for every row drawn in the panel.
398
+ const inPanel = (row) => {
399
+ const at = row.text.indexOf(OPENCODE_BAR);
400
+ return at >= 0 && row.text.slice(0, at).trim() === '' ? at + 1 : -1;
401
+ };
402
+ let head = -1;
403
+ for (let i = rows.length - 1; i >= 0 && head < 0; i--) {
404
+ const at = inPanel(rows[i]);
405
+ if (at >= 0 && OPENCODE_HEADER.test(rows[i].text.slice(at).trimEnd()))
406
+ head = i;
407
+ }
408
+ if (head < 0)
409
+ return undefined;
410
+ // The panel's last row with the key hints is the button row: a request that prints the hint text sits above it.
411
+ let end = head + 1;
412
+ while (end < rows.length && inPanel(rows[end]) >= 0)
413
+ end++;
414
+ let bar = -1;
415
+ for (let i = end - 1; i > head && bar < 0; i--) {
416
+ if (/⇆ select +enter confirm/.test(rows[i].text))
417
+ bar = i;
418
+ }
419
+ if (bar < 0)
420
+ return undefined;
421
+ // The buttons: runs of text split by two or more spaces, up to the first key hint.
422
+ const row = rows[bar];
423
+ const from = inPanel(row);
424
+ const options = [];
425
+ const lit = [];
426
+ for (const m of row.text.slice(from).matchAll(/\S+(?: \S+)*/g)) {
427
+ if (OPENCODE_HINTS.test(m[0]))
428
+ break;
429
+ options.push({ n: options.length + 1, label: m[0], selected: false });
430
+ lit.push(row.bg[from + m.index]);
431
+ }
432
+ // The chosen button: the one background no other button shares, the rest all alike. A theme drawing it in
433
+ // reverse video (or anything else) is not a captured layout.
434
+ if (lit.includes('7'))
435
+ return undefined;
436
+ const odd = lit.filter((bg) => lit.filter((other) => other === bg).length === 1);
437
+ if (odd.length !== 1 || new Set(lit).size !== 2)
438
+ return undefined;
439
+ const selected = lit.indexOf(odd[0]);
440
+ options[selected].selected = true;
441
+ const body = rows.slice(head + 1, bar).map((r) => r.text.slice(inPanel(r)).trimEnd());
442
+ const first = body.findIndex((l) => l.trim() !== '');
443
+ const sub = first < 0 ? null : OPENCODE_SUBTITLE.exec(body[first].trim());
444
+ const detail = dedent(trimBlank(sub ? body.slice(first + 1) : body));
445
+ return assemble({
446
+ region: rows.slice(head, bar + 1).map((r) => r.text.trimEnd()),
447
+ title: 'Permission required',
448
+ ...(sub ? { subtitle: sub[1].trim() } : {}),
449
+ detail,
450
+ question: 'Allow this request?',
451
+ options,
452
+ keys: (at, to) => [...Array(Math.abs(to - at)).fill(to > at ? 'right' : 'left'), 'enter'],
453
+ idSalt: `opencode:selected=${selected}`,
454
+ });
455
+ };
456
+ /** The checks and caps every reader shares: numbering 1..n, one highlight, label and detail limits. */
457
+ const assemble = (d) => {
458
+ const { options } = d;
459
+ if (!d.title || options.length < 2 || options.length > exports.MAX_OPTIONS)
460
+ return undefined;
461
+ if (!options.every((o, i) => o.n === i + 1))
462
+ return undefined;
463
+ const selected = options.findIndex((o) => o.selected);
464
+ if (selected < 0 || options.filter((o) => o.selected).length !== 1)
465
+ return undefined;
466
+ const keys = d.keys ?? keysFor;
467
+ const prompt = {
468
+ id: idOf(d.idSalt === undefined ? d.region : [...d.region, d.idSalt]),
469
+ title: cut(d.title, exports.MAX_LABEL_CHARS),
470
+ detail: d.detail.slice(0, exports.MAX_DETAIL_LINES).map((l) => cut(l, exports.MAX_DETAIL_LINE_CHARS)),
471
+ question: cut(d.question, exports.MAX_LABEL_CHARS),
147
472
  options: options.map((o, i) => ({
148
473
  n: o.n,
149
474
  label: cut(o.label, exports.MAX_LABEL_CHARS),
150
- keys: keysFor(selected, i),
475
+ keys: keys(selected, i),
151
476
  ...(PERSISTENT.test(o.label) ? { persistent: true } : {}),
152
477
  })),
153
478
  };
154
- if (before.length > 0)
155
- prompt.subtitle = cut(before.join(' '), exports.MAX_LABEL_CHARS);
156
- if (after.length > 0)
157
- prompt.note = cut(after.join(' '), exports.MAX_LABEL_CHARS);
158
- if (detailLines.length > exports.MAX_DETAIL_LINES || detailLines.some((l) => l.length > exports.MAX_DETAIL_LINE_CHARS)) {
479
+ if (d.subtitle)
480
+ prompt.subtitle = cut(d.subtitle, exports.MAX_LABEL_CHARS);
481
+ if (d.note)
482
+ prompt.note = cut(d.note, exports.MAX_LABEL_CHARS);
483
+ if (d.detail.length > exports.MAX_DETAIL_LINES || d.detail.some((l) => l.length > exports.MAX_DETAIL_LINE_CHARS)) {
159
484
  prompt.detailTruncated = true;
160
485
  }
161
486
  return prompt;
162
487
  };
488
+ /** One reader per agent kind whose approval dialog was captured. Each takes the pane as captured, escapes and all. */
489
+ const READERS = {
490
+ claude: (pane) => parseClaudeDialog((0, exports.stripEscapes)(pane)),
491
+ gemini: (pane) => parseGeminiDialog((0, exports.stripEscapes)(pane)),
492
+ pi: (pane) => parsePiSelect((0, exports.stripEscapes)(pane)),
493
+ opencode: parseOpencodePermission,
494
+ };
495
+ /** The approval dialog at the bottom of `pane`, read the way `kind` draws it; undefined when it is not one of those. */
496
+ const parseBlockedDialog = (pane, kind) => READERS[kind]?.(pane);
163
497
  exports.parseBlockedDialog = parseBlockedDialog;
498
+ /** True when some reader finds the prompt `id` on `pane`: an id hashes the exact dialog, so the kind need not be known. */
499
+ const showsPrompt = (id, pane) => Object.values(READERS).some((read) => read(pane)?.id === id);
500
+ exports.showsPrompt = showsPrompt;
164
501
  /**
165
502
  * The JSON to seal, within MAX_PROMPT_BYTES: detail lines go from the end until it fits.
166
503
  * Undefined when even a prompt without detail does not fit.
package/dist/config.d.ts CHANGED
@@ -51,6 +51,20 @@ export declare const HERDR_SESSION_OPTIONS: {
51
51
  readonly HERDR_SOCKET_PATH: "@hwip_herdr_socket";
52
52
  readonly HERDR_BIN_PATH: "@hwip_herdr_bin";
53
53
  };
54
+ /**
55
+ * The env herdr gives the processes of its panes, and its native agent integrations
56
+ * (`herdr integration install <agent>`) read to find their pane. Under tmux an agent
57
+ * inherits the tmux server's global env, copied from the terminal that started the
58
+ * server: a herdr pane long closed, never the pane attaching now. The wrapper sets
59
+ * them on every session it creates (wrapper `herdrEnvArgs`).
60
+ */
61
+ export declare const HERDR_ENV_VARS: readonly ["HERDR_ENV", "HERDR_PANE_ID", "HERDR_SOCKET_PATH", "HERDR_BIN_PATH", "HERDR_TAB_ID", "HERDR_WORKSPACE_ID", "HERDR_STARTUP_CWD"];
62
+ /**
63
+ * Agent kinds hwip reports to herdr itself (`HERDR_SESSION_OPTIONS`). herdr's native
64
+ * integration must stay silent for them: a second report on the pane replaces hwip's
65
+ * label, and its release drops both (measured on herdr 0.9.3).
66
+ */
67
+ export declare const HERDR_SELF_REPORTING_KINDS: ReadonlySet<string>;
54
68
  /**
55
69
  * Set by `hwip sh` on the session it opens, to that session's own name. The
56
70
  * one exception to the listener's shell-pane refusal. Only a process on this
package/dist/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.VERSION = exports.saveConfig = exports.configWriteProblem = exports.resolveBaseUrl = exports.checkBaseUrl = exports.fileKeyBaseRefusal = exports.keyIsFromFile = exports.configIgnoredBecause = exports.loadConfig = exports.configModeProblem = exports.detectProjectDir = exports.PROJECT_DIR_ENV_VARS = exports.groupOf = exports.checkoutOf = exports.tmuxName = exports.SESSION_SHELL_OPTION = exports.HERDR_SESSION_OPTIONS = exports.SESSION_LABEL_OPTION = exports.SESSION_PROJECT_OPTION = exports.resolveHookId = exports.resolvedEnv = exports.unreadableConfigDefaults = exports.TRUST_FILE = exports.CONFIG_FILE = exports.CONFIG_DIR = void 0;
3
+ exports.VERSION = exports.saveConfig = exports.configWriteProblem = exports.resolveBaseUrl = exports.checkBaseUrl = exports.fileKeyBaseRefusal = exports.keyIsFromFile = exports.configIgnoredBecause = exports.loadConfig = exports.configModeProblem = exports.detectProjectDir = exports.PROJECT_DIR_ENV_VARS = exports.groupOf = exports.checkoutOf = exports.tmuxName = exports.SESSION_SHELL_OPTION = exports.HERDR_SELF_REPORTING_KINDS = exports.HERDR_ENV_VARS = exports.HERDR_SESSION_OPTIONS = exports.SESSION_LABEL_OPTION = exports.SESSION_PROJECT_OPTION = exports.resolveHookId = exports.resolvedEnv = exports.unreadableConfigDefaults = exports.TRUST_FILE = exports.CONFIG_FILE = exports.CONFIG_DIR = void 0;
4
4
  const child_process_1 = require("child_process");
5
5
  const fs_1 = require("fs");
6
6
  const os_1 = require("os");
@@ -42,6 +42,28 @@ exports.HERDR_SESSION_OPTIONS = {
42
42
  HERDR_SOCKET_PATH: '@hwip_herdr_socket',
43
43
  HERDR_BIN_PATH: '@hwip_herdr_bin',
44
44
  };
45
+ /**
46
+ * The env herdr gives the processes of its panes, and its native agent integrations
47
+ * (`herdr integration install <agent>`) read to find their pane. Under tmux an agent
48
+ * inherits the tmux server's global env, copied from the terminal that started the
49
+ * server: a herdr pane long closed, never the pane attaching now. The wrapper sets
50
+ * them on every session it creates (wrapper `herdrEnvArgs`).
51
+ */
52
+ exports.HERDR_ENV_VARS = [
53
+ 'HERDR_ENV',
54
+ 'HERDR_PANE_ID',
55
+ 'HERDR_SOCKET_PATH',
56
+ 'HERDR_BIN_PATH',
57
+ 'HERDR_TAB_ID',
58
+ 'HERDR_WORKSPACE_ID',
59
+ 'HERDR_STARTUP_CWD',
60
+ ];
61
+ /**
62
+ * Agent kinds hwip reports to herdr itself (`HERDR_SESSION_OPTIONS`). herdr's native
63
+ * integration must stay silent for them: a second report on the pane replaces hwip's
64
+ * label, and its release drops both (measured on herdr 0.9.3).
65
+ */
66
+ exports.HERDR_SELF_REPORTING_KINDS = new Set(['claude', 'pi']);
45
67
  /**
46
68
  * Set by `hwip sh` on the session it opens, to that session's own name. The
47
69
  * one exception to the listener's shell-pane refusal. Only a process on this
@@ -231,12 +231,13 @@ const saveStream = async (source, nextDest, decrypt) => {
231
231
  const counter = new node_stream_1.Transform({
232
232
  transform(chunk, _enc, done) { bytes += chunk.length; done(null, chunk); },
233
233
  });
234
+ let sink;
234
235
  try {
235
236
  // Before the part file exists: a key or an iv the format refuses throws here.
236
237
  const opened = !decrypt ? null : decrypt.chunk === undefined
237
238
  ? (0, exports.createFileDecryptStream)(decrypt.rawKey, decrypt.iv)
238
239
  : (0, exports.createChunkedFileDecryptStream)(decrypt.rawKey, decrypt.iv, decrypt.chunk);
239
- const sink = (0, node_fs_1.createWriteStream)(part, { flags: 'wx' });
240
+ sink = (0, node_fs_1.createWriteStream)(part, { flags: 'wx' });
240
241
  if (opened)
241
242
  await (0, promises_2.pipeline)(source, opened, counter, sink);
242
243
  else
@@ -265,6 +266,11 @@ const saveStream = async (source, nextDest, decrypt) => {
265
266
  return { path: dest, bytes };
266
267
  }
267
268
  catch (err) {
269
+ // A failed pipeline destroys the sink without waiting for its open: wait for
270
+ // the close, or the file can appear after the rm below.
271
+ const opening = sink;
272
+ if (opening && !opening.closed)
273
+ await new Promise((done) => opening.once('close', () => done()));
268
274
  (0, node_fs_1.rmSync)(part, { force: true });
269
275
  throw err;
270
276
  }
@@ -39,6 +39,7 @@ import { silenceListenerLog } from './listener-log.js';
39
39
  import { invalidateTmuxSocketCache, tmuxArgs } from './tmux-socket.js';
40
40
  import { detectRemoteAgent, isSubagentSessionName, recordSubagentTranscripts, recordTargets, subagentTranscriptFor, tmuxTargetFor } from './tmux-pane.js';
41
41
  import { liveSessionNames, sessionExists, sessionLiveness } from './tmux-liveness.js';
42
+ import { type RunSeqs } from './run-seqs.js';
42
43
  import { type PhoneScheduleDeps } from './schedule-remote.js';
43
44
  import { type ScheduleRunnerDeps } from './schedule-runner.js';
44
45
  export declare const PING_IDLE_MS = 240000;
@@ -120,6 +121,16 @@ interface AgentSession {
120
121
  */
121
122
  providerSessionName?: string | null;
122
123
  createdAt?: string;
124
+ /**
125
+ * Which run of the name this is (hwip docs/session-runs.md): tmux
126
+ * `session_created` in epoch seconds, as a decimal string — the same
127
+ * instant as `createdAt`. A name is handed out again when an agent starts
128
+ * in the same folder; this is what tells the two apart. Absent on subagent
129
+ * rows, which are panes of their parent's run.
130
+ */
131
+ runId?: string;
132
+ /** The smallest seq this run's chunks took (hwip docs/session-runs.md Q24): the server reads its conversation from there up. */
133
+ firstSeq?: number;
123
134
  lastActivityAt?: string;
124
135
  /**
125
136
  * Detected agent state (wire contract — flows to the server and the
@@ -196,15 +207,23 @@ export type StaleConnectionAction =
196
207
  * tested as what it is: two guards around one lever.
197
208
  */
198
209
  export declare const staleConnectionAction: (message: unknown, drops: number) => StaleConnectionAction;
199
- export declare const KNOWN_SESSIONS_REPORTED = 30;
210
+ export declare const KNOWN_SESSIONS_REPORTED = 50;
200
211
  export interface ReportedKnownSession {
201
212
  name: string;
202
213
  agentKind: string;
203
214
  project?: string;
204
215
  label?: string;
216
+ /** The run's id and start (hwip docs/session-runs.md); absent for entries recorded before runs. */
217
+ runId?: string;
218
+ startedAt?: string;
219
+ /** The seq block the run's chunks took (Q24): the server reads its conversation from that key range alone. */
220
+ firstSeq?: number;
221
+ lastSeq?: number;
205
222
  lastSeenAt: string;
206
223
  }
207
- export declare const knownSessionsToReport: (live: readonly AgentSession[], now?: number) => ReportedKnownSession[];
224
+ /** Each live run's first seq, for the report (Q24). A run that has recorded nothing yet has none. */
225
+ export declare const withRunSeqs: (sessions: AgentSession[], seqs?: Map<string, RunSeqs>) => AgentSession[];
226
+ export declare const knownSessionsToReport: (live: readonly AgentSession[], now?: number, seqs?: Map<string, RunSeqs>) => ReportedKnownSession[];
208
227
  export declare const knownSessionsFingerprint: (known: ReportedKnownSession[]) => string;
209
228
  export declare const sessionsFingerprint: (sessions: AgentSession[]) => string;
210
229
  /** True when this cycle's inventory must be sent: it changed, or the
@@ -270,6 +289,13 @@ export declare const parseSessionName: (name: string, sidecar?: {
270
289
  } | null;
271
290
  /** Test seam: how many directories have been looked up. */
272
291
  export declare const __checkoutCacheSize: () => number;
292
+ /**
293
+ * A run's id from tmux's `session_created` (hwip docs/session-runs.md): the
294
+ * whole epoch seconds as a decimal string. The server derives the same string
295
+ * from `createdAt` for listeners that do not send one (`runIdOf`), so the two
296
+ * must agree: `epochToIso` keeps whole seconds and this floors them.
297
+ */
298
+ export declare const runIdOfEpoch: (epoch: string | undefined) => string | undefined;
273
299
  /** Test hook. */
274
300
  export declare const resetSessionStates: () => void;
275
301
  /**
@@ -514,6 +540,8 @@ export interface SessionForgetRequest {
514
540
  * the phone deletes the row it is looking at. Absent (older phone) deletes
515
541
  * every run under the name, which is what the request used to mean. */
516
542
  agentKind?: string;
543
+ /** With `agentKind`, the one run of it to delete (hwip docs/session-runs.md). Absent: every run of that agent. */
544
+ runId?: string;
517
545
  requestId?: string;
518
546
  }
519
547
  export interface SessionForgetResult {
@@ -1211,6 +1239,26 @@ export declare const downloadAttachments: (pushId: string, files: PushFileAttach
1211
1239
  apiKey: string;
1212
1240
  baseUrl: string;
1213
1241
  }, sealed?: SealedAttachments, io?: AttachmentIo) => Promise<string[]>;
1242
+ /**
1243
+ * The most a sealed answer file may download as: its seal's size padded, and the tag. The relay
1244
+ * points at the bytes, so it could name a larger object; nothing past this is read.
1245
+ */
1246
+ export declare const answerFileBound: (size: number | undefined) => number;
1247
+ /**
1248
+ * The attachments of a sealed answer (`hwip ask`, answer-files.ts), saved one by one to
1249
+ * `~/.hwip/attachments/<eventId>/<name>`. Unlike a command's, a file that fails does not take the
1250
+ * others with it: the answer is the user's word either way, and each file not saved is named with why.
1251
+ * `sealed.files` gives each file's meta in the same order.
1252
+ */
1253
+ export declare const downloadSealedEach: (eventId: string, files: PushFileAttachment[], ctx: {
1254
+ apiKey: string;
1255
+ baseUrl: string;
1256
+ }, sealed: SealedAttachments & {
1257
+ files: SealedFileMeta[];
1258
+ }, io?: AttachmentIo) => Promise<{
1259
+ paths: string[];
1260
+ missing: string[];
1261
+ }>;
1214
1262
  export interface SaveFileDeps {
1215
1263
  ctx: {
1216
1264
  apiKey: string;