@hwip/cli 0.0.1 → 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.
Files changed (245) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +11 -0
  3. package/README.md +1983 -0
  4. package/dist/agent-list.d.ts +76 -0
  5. package/dist/agent-list.js +102 -0
  6. package/dist/agent-read.d.ts +105 -0
  7. package/dist/agent-read.js +309 -0
  8. package/dist/agent-rules-fetch.d.ts +51 -0
  9. package/dist/agent-rules-fetch.js +275 -0
  10. package/dist/agent-rules.default.d.ts +30 -0
  11. package/dist/agent-rules.default.js +305 -0
  12. package/dist/agent-session.d.ts +23 -0
  13. package/dist/agent-session.js +44 -0
  14. package/dist/agent-state.d.ts +84 -0
  15. package/dist/agent-state.js +188 -0
  16. package/dist/agent-target.d.ts +60 -0
  17. package/dist/agent-target.js +81 -0
  18. package/dist/agents.d.ts +31 -0
  19. package/dist/agents.js +75 -0
  20. package/dist/api.d.ts +28 -0
  21. package/dist/api.js +76 -0
  22. package/dist/args.d.ts +74 -0
  23. package/dist/args.js +254 -0
  24. package/dist/ask-context-attach.d.ts +73 -0
  25. package/dist/ask-context-attach.js +151 -0
  26. package/dist/ask-context-screen.d.ts +21 -0
  27. package/dist/ask-context-screen.js +49 -0
  28. package/dist/ask-context-switch.d.ts +31 -0
  29. package/dist/ask-context-switch.js +74 -0
  30. package/dist/ask-context.d.ts +84 -0
  31. package/dist/ask-context.js +172 -0
  32. package/dist/ask-pending.d.ts +47 -0
  33. package/dist/ask-pending.js +221 -0
  34. package/dist/ask-seal.d.ts +120 -0
  35. package/dist/ask-seal.js +179 -0
  36. package/dist/ask.d.ts +107 -0
  37. package/dist/ask.js +551 -0
  38. package/dist/blocked-prompt-seal.d.ts +39 -0
  39. package/dist/blocked-prompt-seal.js +92 -0
  40. package/dist/blocked-prompt.d.ts +62 -0
  41. package/dist/blocked-prompt.js +518 -0
  42. package/dist/channel.d.ts +67 -0
  43. package/dist/channel.js +136 -0
  44. package/dist/check-update.d.ts +28 -0
  45. package/dist/check-update.js +153 -0
  46. package/dist/cli.d.ts +3 -0
  47. package/dist/cli.js +813 -0
  48. package/dist/codex-config.d.ts +24 -0
  49. package/dist/codex-config.js +169 -0
  50. package/dist/codex-transcript.d.ts +59 -0
  51. package/dist/codex-transcript.js +373 -0
  52. package/dist/command-refusal.d.ts +56 -0
  53. package/dist/command-refusal.js +127 -0
  54. package/dist/command-scan.d.ts +46 -0
  55. package/dist/command-scan.js +154 -0
  56. package/dist/command-seal.d.ts +57 -0
  57. package/dist/command-seal.js +88 -0
  58. package/dist/command-seq.d.ts +30 -0
  59. package/dist/command-seq.js +150 -0
  60. package/dist/config.d.ts +156 -0
  61. package/dist/config.js +392 -0
  62. package/dist/core-sections.d.ts +18 -0
  63. package/dist/core-sections.js +28 -0
  64. package/dist/crypto.d.ts +278 -0
  65. package/dist/crypto.js +698 -0
  66. package/dist/desktop-notify.d.ts +33 -0
  67. package/dist/desktop-notify.js +49 -0
  68. package/dist/device-directory.d.ts +36 -0
  69. package/dist/device-directory.js +97 -0
  70. package/dist/device-presence.d.ts +17 -0
  71. package/dist/device-presence.js +62 -0
  72. package/dist/downloads.d.ts +29 -0
  73. package/dist/downloads.js +106 -0
  74. package/dist/errors.d.ts +12 -0
  75. package/dist/errors.js +28 -0
  76. package/dist/file-crypto.d.ts +94 -0
  77. package/dist/file-crypto.js +278 -0
  78. package/dist/file-view-budget.d.ts +15 -0
  79. package/dist/file-view-budget.js +34 -0
  80. package/dist/file-view-switch.d.ts +28 -0
  81. package/dist/file-view-switch.js +122 -0
  82. package/dist/file-view.d.ts +108 -0
  83. package/dist/file-view.js +327 -0
  84. package/dist/gate.d.ts +141 -0
  85. package/dist/gate.js +336 -0
  86. package/dist/gemini-transcript.d.ts +32 -0
  87. package/dist/gemini-transcript.js +189 -0
  88. package/dist/http-failure.d.ts +98 -0
  89. package/dist/http-failure.js +349 -0
  90. package/dist/hwip-core.generated.d.ts +7 -0
  91. package/dist/hwip-core.generated.js +14 -0
  92. package/dist/hwip-hook.d.ts +77 -0
  93. package/dist/hwip-hook.js +340 -0
  94. package/dist/index.d.ts +7 -0
  95. package/dist/index.js +14 -0
  96. package/dist/input-sequencer.d.ts +65 -0
  97. package/dist/input-sequencer.js +142 -0
  98. package/dist/insecure-url.d.ts +16 -0
  99. package/dist/insecure-url.js +76 -0
  100. package/dist/installer.d.ts +96 -0
  101. package/dist/installer.js +850 -0
  102. package/dist/inventory-offload.d.ts +49 -0
  103. package/dist/inventory-offload.js +209 -0
  104. package/dist/inventory-worker.d.ts +2 -0
  105. package/dist/inventory-worker.js +27 -0
  106. package/dist/jsonc-doc.d.ts +106 -0
  107. package/dist/jsonc-doc.js +354 -0
  108. package/dist/keep-awake.d.ts +32 -0
  109. package/dist/keep-awake.js +46 -0
  110. package/dist/key-changes.d.ts +48 -0
  111. package/dist/key-changes.js +140 -0
  112. package/dist/lan-auth.d.ts +140 -0
  113. package/dist/lan-auth.js +217 -0
  114. package/dist/lan-endpoint.d.ts +41 -0
  115. package/dist/lan-endpoint.js +115 -0
  116. package/dist/lan-receiver.d.ts +122 -0
  117. package/dist/lan-receiver.js +510 -0
  118. package/dist/lan-sender.d.ts +76 -0
  119. package/dist/lan-sender.js +184 -0
  120. package/dist/lan-transfer.d.ts +36 -0
  121. package/dist/lan-transfer.js +54 -0
  122. package/dist/listener-device-id.d.ts +20 -0
  123. package/dist/listener-device-id.js +133 -0
  124. package/dist/listener-log.d.ts +4 -0
  125. package/dist/listener-log.js +23 -0
  126. package/dist/listener-process.d.ts +200 -0
  127. package/dist/listener-process.js +470 -0
  128. package/dist/listener-service.d.ts +225 -0
  129. package/dist/listener-service.js +682 -0
  130. package/dist/listener-worktree-cleanup.d.ts +27 -0
  131. package/dist/listener-worktree-cleanup.js +62 -0
  132. package/dist/listener.d.ts +1325 -0
  133. package/dist/listener.js +5681 -0
  134. package/dist/login-address.d.ts +25 -0
  135. package/dist/login-address.js +97 -0
  136. package/dist/login-page.d.ts +29 -0
  137. package/dist/login-page.js +93 -0
  138. package/dist/login.d.ts +98 -0
  139. package/dist/login.js +388 -0
  140. package/dist/mcp-command.d.ts +45 -0
  141. package/dist/mcp-command.js +50 -0
  142. package/dist/mcp.d.ts +19 -0
  143. package/dist/mcp.js +29 -0
  144. package/dist/node-support.d.ts +19 -0
  145. package/dist/node-support.js +38 -0
  146. package/dist/notify-failure.d.ts +57 -0
  147. package/dist/notify-failure.js +150 -0
  148. package/dist/opencode-transcript.d.ts +139 -0
  149. package/dist/opencode-transcript.js +390 -0
  150. package/dist/optional-features.d.ts +6 -0
  151. package/dist/optional-features.js +19 -0
  152. package/dist/pane-fit.d.ts +40 -0
  153. package/dist/pane-fit.js +119 -0
  154. package/dist/peer-key.d.ts +33 -0
  155. package/dist/peer-key.js +34 -0
  156. package/dist/pi-transcript.d.ts +26 -0
  157. package/dist/pi-transcript.js +255 -0
  158. package/dist/presence.d.ts +14 -0
  159. package/dist/presence.js +124 -0
  160. package/dist/private-files.d.ts +51 -0
  161. package/dist/private-files.js +218 -0
  162. package/dist/project-switch.d.ts +19 -0
  163. package/dist/project-switch.js +60 -0
  164. package/dist/redact.d.ts +10 -0
  165. package/dist/redact.js +21 -0
  166. package/dist/remote-agents.d.ts +410 -0
  167. package/dist/remote-agents.js +1218 -0
  168. package/dist/remote-hook.d.ts +12 -0
  169. package/dist/remote-hook.js +196 -0
  170. package/dist/replace-file.d.ts +50 -0
  171. package/dist/replace-file.js +328 -0
  172. package/dist/schedule-command.d.ts +35 -0
  173. package/dist/schedule-command.js +260 -0
  174. package/dist/schedule-launch.d.ts +15 -0
  175. package/dist/schedule-launch.js +24 -0
  176. package/dist/schedule-ready.d.ts +44 -0
  177. package/dist/schedule-ready.js +71 -0
  178. package/dist/schedule-remote.d.ts +77 -0
  179. package/dist/schedule-remote.js +167 -0
  180. package/dist/schedule-runner.d.ts +89 -0
  181. package/dist/schedule-runner.js +452 -0
  182. package/dist/schedule.d.ts +149 -0
  183. package/dist/schedule.js +428 -0
  184. package/dist/secret-mask.d.ts +29 -0
  185. package/dist/secret-mask.js +245 -0
  186. package/dist/self-register.d.ts +45 -0
  187. package/dist/self-register.js +67 -0
  188. package/dist/send.d.ts +56 -0
  189. package/dist/send.js +104 -0
  190. package/dist/session-diff.d.ts +161 -0
  191. package/dist/session-diff.js +445 -0
  192. package/dist/session-registry.d.ts +143 -0
  193. package/dist/session-registry.js +216 -0
  194. package/dist/sessions-report.d.ts +59 -0
  195. package/dist/sessions-report.js +99 -0
  196. package/dist/shell-audit.d.ts +34 -0
  197. package/dist/shell-audit.js +47 -0
  198. package/dist/subagent-transcripts.d.ts +167 -0
  199. package/dist/subagent-transcripts.js +437 -0
  200. package/dist/subscriber-trust.d.ts +128 -0
  201. package/dist/subscriber-trust.js +397 -0
  202. package/dist/templates.d.ts +105 -0
  203. package/dist/templates.js +871 -0
  204. package/dist/tmux-install.d.ts +44 -0
  205. package/dist/tmux-install.js +65 -0
  206. package/dist/tmux-liveness.d.ts +45 -0
  207. package/dist/tmux-liveness.js +71 -0
  208. package/dist/tmux-pane.d.ts +55 -0
  209. package/dist/tmux-pane.js +126 -0
  210. package/dist/tmux-socket.d.ts +20 -0
  211. package/dist/tmux-socket.js +292 -0
  212. package/dist/todo-fold.d.ts +31 -0
  213. package/dist/todo-fold.js +102 -0
  214. package/dist/transcript-tail.d.ts +312 -0
  215. package/dist/transcript-tail.js +718 -0
  216. package/dist/trust-command.d.ts +49 -0
  217. package/dist/trust-command.js +200 -0
  218. package/dist/trust-lock.d.ts +26 -0
  219. package/dist/trust-lock.js +94 -0
  220. package/dist/trust-pins.d.ts +37 -0
  221. package/dist/trust-pins.js +82 -0
  222. package/dist/turn-recorder-wire.d.ts +70 -0
  223. package/dist/turn-recorder-wire.js +165 -0
  224. package/dist/turn-recorder.d.ts +190 -0
  225. package/dist/turn-recorder.js +695 -0
  226. package/dist/turn-ring.d.ts +93 -0
  227. package/dist/turn-ring.js +418 -0
  228. package/dist/turn-watch.d.ts +177 -0
  229. package/dist/turn-watch.js +630 -0
  230. package/dist/types.d.ts +63 -0
  231. package/dist/types.js +2 -0
  232. package/dist/uninstall.d.ts +55 -0
  233. package/dist/uninstall.js +507 -0
  234. package/dist/usage.d.ts +17 -0
  235. package/dist/usage.js +421 -0
  236. package/dist/verify.d.ts +47 -0
  237. package/dist/verify.js +396 -0
  238. package/dist/worktree.d.ts +144 -0
  239. package/dist/worktree.js +310 -0
  240. package/dist/wrapper.d.ts +254 -0
  241. package/dist/wrapper.js +704 -0
  242. package/dist/ws-url.d.ts +109 -0
  243. package/dist/ws-url.js +202 -0
  244. package/package.json +78 -10
  245. package/bin/hwip.js +0 -3
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.promptStillShown = exports.createPromptSealer = exports.PROMPT_SEAL_RETRY_MS = void 0;
4
+ /**
5
+ * Turns the dialog a blocked session shows into the sealed summary the session
6
+ * report carries (`blockedPrompt`, hwip SERVER-API.md).
7
+ *
8
+ * `observe` is called once per session per report cycle, synchronously, with the
9
+ * pane the state was read from. Sealing is async (key lookup, ECDH), so the first
10
+ * cycle that sees a prompt starts the seal and `sealing()` says when it is done:
11
+ * the listener holds that report for it a short while (`withSettledPrompts`), since
12
+ * the server pushes the moment a session turns blocked and the phone reads the
13
+ * prompt for the push's buttons right then. The sealed object is kept while the
14
+ * prompt id is unchanged, so an unchanged prompt makes an unchanged report.
15
+ *
16
+ * A prompt that cannot be parsed, has no one to seal for, or fails to seal is left
17
+ * out: the session reports the bare `blocked` state, as it did before. Nothing
18
+ * here logs the dialog text, the plaintext or the ciphertext.
19
+ */
20
+ const blocked_prompt_js_1 = require("./blocked-prompt.js");
21
+ /** After a failed seal or an empty recipient list, how long before the same prompt is tried again. */
22
+ exports.PROMPT_SEAL_RETRY_MS = 30_000;
23
+ const createPromptSealer = (deps) => {
24
+ const now = deps.now ?? Date.now;
25
+ const entries = new Map();
26
+ const start = (name, entry, plaintext) => {
27
+ entry.busy = true;
28
+ entry.sealing = (async () => {
29
+ try {
30
+ const recipients = await deps.recipients();
31
+ if (recipients.length === 0) {
32
+ entry.retryAt = now() + exports.PROMPT_SEAL_RETRY_MS;
33
+ return;
34
+ }
35
+ const sealed = await deps.seal(plaintext, recipients);
36
+ entry.wire = { id: entry.id, at: entry.at, sealed };
37
+ }
38
+ catch (err) {
39
+ // The message of a crypto error names no content; the dialog text is never in it.
40
+ deps.log?.(`blocked prompt for ${name}: seal failed (${err instanceof Error ? err.message : String(err)})`);
41
+ entry.retryAt = now() + exports.PROMPT_SEAL_RETRY_MS;
42
+ }
43
+ finally {
44
+ entry.busy = false;
45
+ entry.sealing = undefined;
46
+ }
47
+ })();
48
+ };
49
+ return {
50
+ observe: (name, kind, state, paneText) => {
51
+ const parsed = state === 'blocked' && paneText !== null ? (0, blocked_prompt_js_1.parseBlockedDialog)(paneText, kind) : undefined;
52
+ if (!parsed) {
53
+ entries.delete(name);
54
+ return undefined;
55
+ }
56
+ let entry = entries.get(name);
57
+ if (entry?.id !== parsed.id) {
58
+ entry = { id: parsed.id, at: new Date(now()).toISOString(), busy: false };
59
+ entries.set(name, entry);
60
+ }
61
+ if (entry.wire)
62
+ return entry.wire;
63
+ if (!entry.busy && (entry.retryAt === undefined || now() >= entry.retryAt)) {
64
+ entry.retryAt = undefined;
65
+ const plaintext = (0, blocked_prompt_js_1.promptPlaintext)(parsed, { sessionName: name, deviceId: deps.deviceId(), at: entry.at });
66
+ // A prompt too big for the cap is not retried: the screen would not shrink.
67
+ if (plaintext === undefined)
68
+ entry.retryAt = Number.POSITIVE_INFINITY;
69
+ else
70
+ start(name, entry, plaintext);
71
+ }
72
+ return undefined;
73
+ },
74
+ prune: (live) => {
75
+ for (const name of entries.keys()) {
76
+ if (!live.has(name))
77
+ entries.delete(name);
78
+ }
79
+ },
80
+ sealing: () => {
81
+ const running = [...entries.values()].flatMap((e) => (e.sealing ? [e.sealing] : []));
82
+ return running.length > 0 ? Promise.all(running).then(() => undefined) : undefined;
83
+ },
84
+ };
85
+ };
86
+ exports.createPromptSealer = createPromptSealer;
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);
92
+ exports.promptStillShown = promptStillShown;
@@ -0,0 +1,62 @@
1
+ import type { AgentKind } from './remote-agents.js';
2
+ /** Dialog titles whose layout was captured and is read. */
3
+ export declare const KNOWN_TITLES: readonly string[];
4
+ export declare const MAX_DETAIL_LINES = 12;
5
+ export declare const MAX_DETAIL_LINE_CHARS = 300;
6
+ export declare const MAX_LABEL_CHARS = 200;
7
+ export declare const MAX_OPTIONS = 9;
8
+ /** The plaintext a prompt is sealed from stays under this; the server's cap on the sealed body is 8 KiB. */
9
+ export declare const MAX_PROMPT_BYTES: number;
10
+ export interface PromptOption {
11
+ /** The number the dialog shows. */
12
+ n: number;
13
+ label: string;
14
+ /** Named keys (`ALLOWED_KEYS` in listener.ts) that pick this option from the highlight the pane shows now. */
15
+ keys: string[];
16
+ /** Choosing it grants more than this one request ("don't ask again", "always", "for this session"). */
17
+ persistent?: true;
18
+ }
19
+ export interface ParsedPrompt {
20
+ /** Hash of the dialog as shown, highlight included: moving the highlight is a different prompt. */
21
+ id: string;
22
+ title: string;
23
+ subtitle?: string;
24
+ detail: string[];
25
+ detailTruncated?: true;
26
+ note?: string;
27
+ question: string;
28
+ options: PromptOption[];
29
+ }
30
+ export declare const isPromptId: (value: unknown) => value is string;
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;
37
+ /** What the app opens: the prompt plus which session and listener it belongs to, and when it was first seen. */
38
+ export interface PromptPlaintext {
39
+ v: 1;
40
+ sessionName: string;
41
+ deviceId: string;
42
+ at: string;
43
+ id: string;
44
+ kind: 'permission';
45
+ title: string;
46
+ subtitle?: string;
47
+ detail: string[];
48
+ detailTruncated?: true;
49
+ note?: string;
50
+ question: string;
51
+ options: PromptOption[];
52
+ }
53
+ /**
54
+ * The JSON to seal, within MAX_PROMPT_BYTES: detail lines go from the end until it fits.
55
+ * Undefined when even a prompt without detail does not fit.
56
+ */
57
+ export declare const promptPlaintext: (prompt: ParsedPrompt, ctx: {
58
+ sessionName: string;
59
+ deviceId: string;
60
+ at: string;
61
+ }) => string | undefined;
62
+ //# sourceMappingURL=blocked-prompt.d.ts.map
@@ -0,0 +1,518 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
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
+ /**
5
+ * What a blocked agent session is waiting for, read off its screen.
6
+ *
7
+ * The state detector (`agent-state.ts`) only says "blocked". The app wants to
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, with a
10
+ * reader per agent kind (`parseBlockedDialog`). Pure: the listener owns tmux,
11
+ * sealing and timing (`blocked-prompt-seal.ts`).
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
18
+ * `blocked-prompt.test.ts`):
19
+ *
20
+ * ───────────── full-width rule: the dialog starts below the last one
21
+ * Bash command title
22
+ * Run shell command subtitle (optional)
23
+ * ╌╌╌╌╌╌╌╌╌╌╌╌╌ dashed rule
24
+ * rtk ls -la . detail (the command, or the diff of an edit)
25
+ * ╌╌╌╌╌╌╌╌╌╌╌╌╌
26
+ * This command requires approval note (optional)
27
+ *
28
+ * Do you want to proceed? question
29
+ * ❯ 1. Yes options, `❯` marks the highlighted one
30
+ * 2. Yes, and don’t ask again for: rtk ls *
31
+ * 3. No
32
+ *
33
+ * Esc to cancel · Tab to amend footer
34
+ *
35
+ * Only the titles in `KNOWN_TITLES` are read. Another dialog (a plan, a
36
+ * question to the user, an MCP tool) has no captured layout, so it yields
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`).
49
+ */
50
+ const node_crypto_1 = require("node:crypto");
51
+ /** Dialog titles whose layout was captured and is read. */
52
+ exports.KNOWN_TITLES = ['Bash command', 'Edit file', 'Create file'];
53
+ exports.MAX_DETAIL_LINES = 12;
54
+ exports.MAX_DETAIL_LINE_CHARS = 300;
55
+ exports.MAX_LABEL_CHARS = 200;
56
+ exports.MAX_OPTIONS = 9;
57
+ /** The plaintext a prompt is sealed from stays under this; the server's cap on the sealed body is 8 KiB. */
58
+ exports.MAX_PROMPT_BYTES = 2 * 1024;
59
+ /** The shape of a prompt id on the wire: what `ifPrompt` may carry (the server checks the same). */
60
+ const PROMPT_ID = /^[A-Za-z0-9_-]{1,64}$/;
61
+ const isPromptId = (value) => typeof value === 'string' && PROMPT_ID.test(value);
62
+ exports.isPromptId = isPromptId;
63
+ const FULL_RULE = /^─{20,}$/;
64
+ const DASHED_RULE = /^╌{20,}$/;
65
+ const OPTION = /^\s*([❯>])?\s*(\d{1,2})\.\s+(\S.*)$/;
66
+ const FOOTER = /^\s*(esc to cancel|enter to select)/i;
67
+ // A choice that grants more than this one request: "don't ask again", "always allow", "allow all edits during this
68
+ // session", "… for this session". Wider on purpose — a miss lets the app skip its extra confirmation.
69
+ const PERSISTENT = /don.?t ask again|never ask|always|allow all|this session|from now on/i;
70
+ /** The character the highlight is drawn with. A pane without it is not a dialog this module can answer. */
71
+ const HIGHLIGHT = '❯';
72
+ const cut = (text, max) => {
73
+ if (text.length <= max)
74
+ return text;
75
+ const head = text.slice(0, max);
76
+ const last = head.charCodeAt(head.length - 1);
77
+ return last >= 0xd800 && last <= 0xdbff ? head.slice(0, -1) : head;
78
+ };
79
+ const idOf = (region) => (0, node_crypto_1.createHash)('sha256').update(region.join('\n')).digest('hex').slice(0, 16);
80
+ /** `down`×k or `up`×k to reach option `to` from option `from`, then `enter`. */
81
+ const keysFor = (from, to) => [...Array(Math.abs(to - from)).fill(to > from ? 'down' : 'up'), 'enter'];
82
+ /** Claude Code's dialog at the bottom of `pane`, or undefined when it is not one this module reads. */
83
+ const parseClaudeDialog = (pane) => {
84
+ const lines = pane.split('\n').map((l) => l.trimEnd());
85
+ // The question is the last "…?" line whose next non-blank line is option 1.
86
+ let qAt = -1;
87
+ for (let i = lines.length - 2; i >= 0; i--) {
88
+ if (!lines[i].endsWith('?'))
89
+ continue;
90
+ const next = lines.slice(i + 1).find((l) => l !== '');
91
+ if (next !== undefined && OPTION.exec(next)?.[2] === '1') {
92
+ qAt = i;
93
+ break;
94
+ }
95
+ }
96
+ if (qAt < 0)
97
+ return undefined;
98
+ // The dialog starts below the last full-width rule above the question and ends at its footer;
99
+ // whatever the terminal draws below (a status line, its own rule) is not part of it.
100
+ let top = -1;
101
+ for (let i = qAt - 1; i >= 0; i--) {
102
+ if (FULL_RULE.test(lines[i])) {
103
+ top = i;
104
+ break;
105
+ }
106
+ }
107
+ if (top < 0)
108
+ return undefined;
109
+ let end = lines.length;
110
+ while (end > qAt + 1 && lines[end - 1] === '')
111
+ end--;
112
+ for (let i = qAt + 1; i < end; i++) {
113
+ if (FOOTER.test(lines[i])) {
114
+ end = i + 1;
115
+ break;
116
+ }
117
+ }
118
+ const region = lines.slice(top + 1, end);
119
+ const q = qAt - (top + 1);
120
+ const options = [];
121
+ for (const line of region.slice(q + 1)) {
122
+ if (line === '' || FOOTER.test(line))
123
+ continue;
124
+ const m = OPTION.exec(line);
125
+ if (m) {
126
+ options.push({ n: Number(m[2]), label: m[3].trim(), selected: m[1] === HIGHLIGHT });
127
+ }
128
+ else if (options.length > 0 && /^\s{3,}\S/.test(line)) {
129
+ // A long label wraps onto an indented line.
130
+ const last = options[options.length - 1];
131
+ last.label = `${last.label} ${line.trim()}`;
132
+ }
133
+ else {
134
+ return undefined;
135
+ }
136
+ }
137
+ // Above the question: title, subtitle, dashed rule, detail, dashed rule, note.
138
+ const head = region.slice(0, q);
139
+ const title = head.find((l) => l.trim() !== '')?.trim();
140
+ if (!title || !exports.KNOWN_TITLES.includes(title))
141
+ return undefined;
142
+ const body = head.slice(head.findIndex((l) => l.trim() !== '') + 1);
143
+ const firstDash = body.findIndex((l) => DASHED_RULE.test(l));
144
+ const before = (firstDash < 0 ? body : body.slice(0, firstDash)).map((l) => l.trim()).filter(Boolean);
145
+ const rest = firstDash < 0 ? [] : body.slice(firstDash + 1);
146
+ const secondDash = rest.findIndex((l) => DASHED_RULE.test(l));
147
+ const detailLines = (secondDash < 0 ? rest : rest.slice(0, secondDash)).map((l) => l.replace(/^ /, ''));
148
+ while (detailLines.length > 0 && detailLines[detailLines.length - 1].trim() === '')
149
+ detailLines.pop();
150
+ const after = secondDash < 0 ? [] : rest.slice(secondDash + 1).map((l) => l.trim()).filter(Boolean);
151
+ return assemble({
152
+ region,
153
+ title,
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),
472
+ options: options.map((o, i) => ({
473
+ n: o.n,
474
+ label: cut(o.label, exports.MAX_LABEL_CHARS),
475
+ keys: keys(selected, i),
476
+ ...(PERSISTENT.test(o.label) ? { persistent: true } : {}),
477
+ })),
478
+ };
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)) {
484
+ prompt.detailTruncated = true;
485
+ }
486
+ return prompt;
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);
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;
501
+ /**
502
+ * The JSON to seal, within MAX_PROMPT_BYTES: detail lines go from the end until it fits.
503
+ * Undefined when even a prompt without detail does not fit.
504
+ */
505
+ const promptPlaintext = (prompt, ctx) => {
506
+ const { id, ...fields } = prompt;
507
+ const base = { v: 1, ...ctx, id, kind: 'permission', ...fields };
508
+ let json = JSON.stringify(base);
509
+ while (Buffer.byteLength(json) > exports.MAX_PROMPT_BYTES) {
510
+ if (base.detail.length === 0)
511
+ return undefined;
512
+ base.detail = base.detail.slice(0, -1);
513
+ base.detailTruncated = true;
514
+ json = JSON.stringify(base);
515
+ }
516
+ return json;
517
+ };
518
+ exports.promptPlaintext = promptPlaintext;