@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 +7 -3
- package/dist/agent-rules.default.d.ts +1 -1
- package/dist/agent-rules.default.js +34 -21
- 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/file-crypto.js +7 -1
- package/dist/listener.js +7 -6
- package/package.json +1 -1
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
|
|
650
|
-
|
|
651
|
-
and
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
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
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
88
|
-
|
|
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;
|
package/dist/blocked-prompt.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
31
|
-
export declare const
|
|
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;
|
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/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.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
|
-
|
|
704
|
-
const
|
|
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,
|
|
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;
|