@hwip/cli 0.1.0 → 0.1.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/README.md CHANGED
@@ -646,9 +646,13 @@ transcript, then forgotten. A session that has been quiet for a long time keeps
646
646
  state while its transcript exists, so nothing already sent is sent again (an OpenCode
647
647
  session, which has no file to check, for 30 days).
648
648
 
649
- When a session waits for your approval (a Bash command, a file edit or a new file),
650
- the listener also sends what it is waiting for — the tool, the command or the diff,
651
- and the numbered answers — so the app can show it and answer. It is sealed the same
649
+ When a session waits for your approval, the listener also sends what it is waiting
650
+ for — the tool, the command or the diff, and the answers — so the app can show it
651
+ and answer. That covers Claude Code (a Bash command, a file edit or a new file),
652
+ Gemini CLI (its tool approvals), OpenCode (its permission request; the listener reads
653
+ the screen with its colours, since only a colour marks the chosen button) and pi
654
+ approval extensions that ask through `ctx.ui.select`. Codex's approval dialog is not
655
+ read yet. It is sealed the same
652
656
  way as a chunk, for the same pinned devices, and turned off by the same
653
657
  `--no-turn-history`; the server stores ciphertext only. Other dialogs (a plan, a
654
658
  question, a trust prompt) send nothing beyond the state. An answer the app sends is
@@ -18,7 +18,7 @@
18
18
  * still be working underneath, so it must not change the state.
19
19
  *
20
20
  * Every agent here carries the same rules the server manifest publishes
21
- * (hwip server/src/agent-rules/manifest.json, version 2026.10.15.1 — hwip#484),
21
+ * (hwip server/src/agent-rules/manifest.json, version 2026.10.18.1 — hwip#532, merged and deployed before this cli ships),
22
22
  * so a listener that never reaches the endpoint classifies them too. A fetched
23
23
  * manifest replaces this bundle whole for state reports; schedules read only
24
24
  * this bundle. An agent without rules here
@@ -21,7 +21,7 @@ exports.DEFAULT_MANIFEST = void 0;
21
21
  * still be working underneath, so it must not change the state.
22
22
  *
23
23
  * Every agent here carries the same rules the server manifest publishes
24
- * (hwip server/src/agent-rules/manifest.json, version 2026.10.15.1 — hwip#484),
24
+ * (hwip server/src/agent-rules/manifest.json, version 2026.10.18.1 — hwip#532, merged and deployed before this cli ships),
25
25
  * so a listener that never reaches the endpoint classifies them too. A fetched
26
26
  * manifest replaces this bundle whole for state reports; schedules read only
27
27
  * this bundle. An agent without rules here
@@ -31,7 +31,7 @@ exports.DEFAULT_MANIFEST = void 0;
31
31
  const agent_state_js_1 = require("./agent-state.js");
32
32
  exports.DEFAULT_MANIFEST = {
33
33
  engineVersion: agent_state_js_1.ENGINE_VERSION,
34
- version: '2026.10.15.1',
34
+ version: '2026.10.18.1',
35
35
  agents: {
36
36
  claude: [
37
37
  {
@@ -185,14 +185,27 @@ exports.DEFAULT_MANIFEST = {
185
185
  ],
186
186
  gemini: [
187
187
  {
188
- // KNOWN GAP — `blocked` here means the folder-trust dialog,
189
- // the only blocked screen that was actually captured. Gemini's
190
- // tool-approval prompt is the common blocked case in practice
191
- // and has NO rule: the probe session had shell auto-approved,
192
- // so it never rendered. Until someone captures it, a gemini
193
- // session waiting on tool approval reports `working` (the
194
- // spinner rule) or `idle`, not `blocked`. Do not read the
195
- // bench matrix's green `blocked` cell as full coverage.
188
+ // Tool approval (Shell, Edit, WriteFile), captured 2026-10-18 on
189
+ // Gemini CLI v0.46.0: a rounded box whose answers start with
190
+ // `│ ● 1. Allow once` under a boxed question line. Keyed on
191
+ // both, not the bare phrase, so agent text or a tool output box
192
+ // printing the answer row is not a dialog. The
193
+ // dialog draws no footer and hides the input box; 30 lines
194
+ // because a long diff sits between the header and the answers
195
+ // and a later notice box can be drawn below it.
196
+ id: 'gemini-blocked-tool-approval',
197
+ state: 'blocked',
198
+ priority: 900,
199
+ tailLines: 30,
200
+ regex: ['^\\s*│\\s+(?:●\\s+)?1\\.\\s+allow once'],
201
+ any: [
202
+ { regex: ['^\\s*│ allow execution of .*\\?\\s*│$'] },
203
+ { regex: ['^\\s*│ apply this change\\?\\s*│$'] },
204
+ ],
205
+ // The input box is hidden while Gemini asks; with it on screen the box is the agent's own text.
206
+ not: [{ contains: ['type your message'] }],
207
+ },
208
+ {
196
209
  // Observed on Gemini CLI v0.46.0.
197
210
  id: 'gemini-blocked-trust-dialog',
198
211
  state: 'blocked',
@@ -215,17 +228,17 @@ exports.DEFAULT_MANIFEST = {
215
228
  },
216
229
  ],
217
230
  opencode: [
218
- // Hand-written from the screen strings recorded in the C136
219
- // backlog row (opencode 1.18.35) and a user's permission screen
220
- // (`Allow once Always allow Reject … enter confirm`), not
221
- // from a pane capture, so the bench has no fixture for it and
222
- // agent-state.test.ts pins the strings instead. The button
223
- // wording differs between versions (`Allow always` vs `Always
224
- // allow`), hence the rule keys on the `Permission required`
225
- // title line, guarded by any button or footer string so the
226
- // phrase in tool output or agent text alone is not a dialog. tailLines 30 because an edit request draws its diff
227
- // between that line and the buttons. Other prompts (the agent
228
- // asking a question) are not captured and report `unknown`.
231
+ // Written from the screen strings recorded in the C136 backlog
232
+ // row (opencode 1.18.35) and a user's permission screen, then
233
+ // checked against panes captured 2026-10-18 on opencode 2.0.29
234
+ // (harness bench: bash and edit requests). The button wording
235
+ // differs between versions (`Allow always` vs `Always allow`),
236
+ // hence the rule keys on the `Permission required` title line,
237
+ // guarded by any button or footer string so the phrase in tool
238
+ // output or agent text alone is not a dialog. tailLines 30
239
+ // because an edit request draws its diff between that line and
240
+ // the buttons. Other prompts (the agent asking a question) are
241
+ // not captured and report `unknown`.
229
242
  // No idle rule: at rest the footer is only `ctrl+p commands`,
230
243
  // which says nothing the absence of `esc interrupt` does not.
231
244
  {
@@ -1,4 +1,5 @@
1
1
  import type { AgentState } from './agent-state.js';
2
+ import type { AgentKind } from './remote-agents.js';
2
3
  import type { RecorderRecipient, SealedChunk } from './turn-recorder.js';
3
4
  /** What the session report carries: the server stores it and never opens it. */
4
5
  export interface BlockedPromptWire {
@@ -23,13 +24,16 @@ export interface PromptSealerDeps {
23
24
  }
24
25
  export interface PromptSealer {
25
26
  /** The sealed prompt for this session's screen, or undefined (not blocked, not a known dialog, not sealed yet). */
26
- observe: (name: string, state: AgentState | undefined, paneText: string | null) => BlockedPromptWire | undefined;
27
+ observe: (name: string, kind: AgentKind, state: AgentState | undefined, paneText: string | null) => BlockedPromptWire | undefined;
27
28
  /** Forget sessions that are gone. */
28
29
  prune: (live: ReadonlySet<string>) => void;
29
30
  /** Settles when the seals running now are done, or undefined when none is. */
30
31
  sealing: () => Promise<void> | undefined;
31
32
  }
32
33
  export declare const createPromptSealer: (deps: PromptSealerDeps) => PromptSealer;
33
- /** True when the pane shows the prompt `id` right now (a fresh capture, not the one a report was made from). */
34
+ /**
35
+ * True when the pane shows the prompt `id` right now (a fresh capture, not the one a report was made from). Capture it
36
+ * the way `observe` was fed (`-e`): OpenCode's highlight is in the escapes, and the id with it.
37
+ */
34
38
  export declare const promptStillShown: (id: string, paneText: string | null) => boolean;
35
39
  //# sourceMappingURL=blocked-prompt-seal.d.ts.map
@@ -47,8 +47,8 @@ const createPromptSealer = (deps) => {
47
47
  })();
48
48
  };
49
49
  return {
50
- observe: (name, state, paneText) => {
51
- const parsed = state === 'blocked' && paneText !== null ? (0, blocked_prompt_js_1.parseBlockedDialog)(paneText) : undefined;
50
+ observe: (name, kind, state, paneText) => {
51
+ const parsed = state === 'blocked' && paneText !== null ? (0, blocked_prompt_js_1.parseBlockedDialog)(paneText, kind) : undefined;
52
52
  if (!parsed) {
53
53
  entries.delete(name);
54
54
  return undefined;
@@ -84,6 +84,9 @@ const createPromptSealer = (deps) => {
84
84
  };
85
85
  };
86
86
  exports.createPromptSealer = createPromptSealer;
87
- /** True when the pane shows the prompt `id` right now (a fresh capture, not the one a report was made from). */
88
- const promptStillShown = (id, paneText) => paneText !== null && (0, blocked_prompt_js_1.parseBlockedDialog)(paneText)?.id === id;
87
+ /**
88
+ * True when the pane shows the prompt `id` right now (a fresh capture, not the one a report was made from). Capture it
89
+ * the way `observe` was fed (`-e`): OpenCode's highlight is in the escapes, and the id with it.
90
+ */
91
+ const promptStillShown = (id, paneText) => paneText !== null && (0, blocked_prompt_js_1.showsPrompt)(id, paneText);
89
92
  exports.promptStillShown = promptStillShown;
@@ -1,3 +1,4 @@
1
+ import type { AgentKind } from './remote-agents.js';
1
2
  /** Dialog titles whose layout was captured and is read. */
2
3
  export declare const KNOWN_TITLES: readonly string[];
3
4
  export declare const MAX_DETAIL_LINES = 12;
@@ -27,8 +28,12 @@ export interface ParsedPrompt {
27
28
  options: PromptOption[];
28
29
  }
29
30
  export declare const isPromptId: (value: unknown) => value is string;
30
- /** The dialog at the bottom of `pane`, or undefined when it is not one this module reads. */
31
- export declare const parseBlockedDialog: (pane: string) => ParsedPrompt | undefined;
31
+ /** `pane` without terminal escapes: what `capture-pane` without `-e` gives. */
32
+ export declare const stripEscapes: (pane: string) => string;
33
+ /** The approval dialog at the bottom of `pane`, read the way `kind` draws it; undefined when it is not one of those. */
34
+ export declare const parseBlockedDialog: (pane: string, kind: AgentKind) => ParsedPrompt | undefined;
35
+ /** True when some reader finds the prompt `id` on `pane`: an id hashes the exact dialog, so the kind need not be known. */
36
+ export declare const showsPrompt: (id: string, pane: string) => boolean;
32
37
  /** What the app opens: the prompt plus which session and listener it belongs to, and when it was first seen. */
33
38
  export interface PromptPlaintext {
34
39
  v: 1;
@@ -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.
@@ -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
  }
package/dist/listener.js CHANGED
@@ -632,13 +632,13 @@ const resetSessionStates = () => {
632
632
  sessionStates.clear();
633
633
  };
634
634
  exports.resetSessionStates = resetSessionStates;
635
- const capturePaneText = (session, target) => {
635
+ const capturePaneText = (session, target, escapes = false) => {
636
636
  // Explicit target for the sweep (subagent rows aren't in the recorded
637
637
  // target map yet); otherwise resolve through it.
638
638
  const resolved = target ?? (0, tmux_pane_js_1.tmuxTargetFor)(session);
639
639
  if (resolved === null)
640
640
  return null; // unresolvable subagent — never spawn
641
- const r = (0, child_process_1.spawnSync)('tmux', (0, tmux_socket_js_1.tmuxArgs)(['capture-pane', '-p', '-t', resolved, '-S', `-${STATE_CAPTURE_LINES}`]), {
641
+ const r = (0, child_process_1.spawnSync)('tmux', (0, tmux_socket_js_1.tmuxArgs)(['capture-pane', '-p', ...(escapes ? ['-e'] : []), '-t', resolved, '-S', `-${STATE_CAPTURE_LINES}`]), {
642
642
  encoding: 'utf-8',
643
643
  stdio: ['ignore', 'pipe', 'pipe'],
644
644
  });
@@ -700,8 +700,9 @@ const withBlockedPrompts = ({ sessions, targets }) => {
700
700
  const target = targets[session.name];
701
701
  if (session.parentName !== undefined || session.agentKind === 'shell' || target === undefined)
702
702
  return session;
703
- const pane = session.state === 'blocked' ? capturePaneText(session.name, target) : null;
704
- const blockedPrompt = sealer.observe(session.name, session.state, pane);
703
+ // With escapes: OpenCode draws its chosen button as a background colour only. `deliverInput` captures the same way.
704
+ const pane = session.state === 'blocked' ? capturePaneText(session.name, target, true) : null;
705
+ const blockedPrompt = sealer.observe(session.name, session.agentKind, session.state, pane);
705
706
  return blockedPrompt ? { ...session, blockedPrompt } : session;
706
707
  });
707
708
  sealer.prune(new Set(sessions.map((s) => s.name)));
@@ -2961,7 +2962,7 @@ const deliverInput = (input) => {
2961
2962
  const deps = { noteRefusal: (r) => { reason ??= r; } };
2962
2963
  // Before anything is spent on it: a key bound to a prompt that is gone must not
2963
2964
  // take a rate-limit token, and must not land on whatever is on screen now.
2964
- const bound = input.ifPrompt === undefined || (0, blocked_prompt_seal_js_1.promptStillShown)(input.ifPrompt, capturePaneText(input.sessionName));
2965
+ const bound = input.ifPrompt === undefined || (0, blocked_prompt_seal_js_1.promptStillShown)(input.ifPrompt, capturePaneText(input.sessionName, undefined, true));
2965
2966
  if (!bound)
2966
2967
  reason = 'prompt_changed';
2967
2968
  const injected = bound && hasStream(input.sessionName)
@@ -4508,7 +4509,7 @@ const handlePushInner = async (push, deps) => {
4508
4509
  return handleScheduleRequest(push, push.agentSessionName, addressed, deps);
4509
4510
  // A command bound to a blocked prompt types only while that prompt is on screen. Checked
4510
4511
  // before anything is downloaded or typed, and before the rate limit is spent.
4511
- if (push.ifPrompt !== undefined && !(0, blocked_prompt_seal_js_1.promptStillShown)(push.ifPrompt, (deps.capturePane ?? capturePaneText)(push.agentSessionName))) {
4512
+ if (push.ifPrompt !== undefined && !(0, blocked_prompt_seal_js_1.promptStillShown)(push.ifPrompt, deps.capturePane ? deps.capturePane(push.agentSessionName) : capturePaneText(push.agentSessionName, undefined, true))) {
4512
4513
  (0, listener_log_js_1.log)(`! ${push.agentSessionName}: the blocked prompt this command answers is gone — drop`);
4513
4514
  deps.noteRefusal?.('prompt_changed');
4514
4515
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hwip/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Hwip CLI + push notification SDK for AI agents",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",