@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.
- package/README.md +28 -4
- package/dist/agent-rules.default.d.ts +1 -1
- package/dist/agent-rules.default.js +34 -21
- package/dist/answer-files.d.ts +39 -0
- package/dist/answer-files.js +35 -0
- package/dist/ask-seal.d.ts +5 -1
- package/dist/ask-seal.js +4 -1
- package/dist/ask.d.ts +13 -0
- package/dist/ask.js +37 -6
- package/dist/blocked-prompt-seal.d.ts +6 -2
- package/dist/blocked-prompt-seal.js +7 -4
- package/dist/blocked-prompt.d.ts +7 -2
- package/dist/blocked-prompt.js +361 -24
- package/dist/config.d.ts +14 -0
- package/dist/config.js +23 -1
- package/dist/file-crypto.js +7 -1
- package/dist/listener.d.ts +50 -2
- package/dist/listener.js +166 -36
- package/dist/remote-agents.d.ts +22 -0
- package/dist/remote-agents.js +22 -5
- package/dist/run-seqs.d.ts +28 -0
- package/dist/run-seqs.js +79 -0
- package/dist/session-registry.d.ts +34 -4
- package/dist/session-registry.js +50 -14
- package/dist/templates.js +16 -5
- package/dist/tmux-liveness.d.ts +7 -0
- package/dist/tmux-liveness.js +22 -1
- package/dist/turn-recorder-wire.js +1 -1
- package/dist/turn-recorder.d.ts +8 -0
- package/dist/turn-recorder.js +10 -2
- package/dist/wrapper.d.ts +12 -4
- package/dist/wrapper.js +47 -6
- package/package.json +16 -2
package/dist/blocked-prompt.js
CHANGED
|
@@ -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
|
|
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
|
|
10
|
-
*
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
67
|
-
const
|
|
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
|
-
|
|
143
|
-
|
|
151
|
+
return assemble({
|
|
152
|
+
region,
|
|
144
153
|
title,
|
|
145
|
-
|
|
146
|
-
|
|
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:
|
|
475
|
+
keys: keys(selected, i),
|
|
151
476
|
...(PERSISTENT.test(o.label) ? { persistent: true } : {}),
|
|
152
477
|
})),
|
|
153
478
|
};
|
|
154
|
-
if (
|
|
155
|
-
prompt.subtitle = cut(
|
|
156
|
-
if (
|
|
157
|
-
prompt.note = cut(
|
|
158
|
-
if (
|
|
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
|
package/dist/file-crypto.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/listener.d.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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;
|