@hwip/cli 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -535,6 +535,11 @@ offers it during setup and `hwip uninstall` takes it away again.
535
535
  What you get back after a reboot is your **past sessions**, ready to
536
536
  resume from the phone — not live agents. tmux does not survive a reboot
537
537
  either, and this doesn't try to bring those sessions back to life.
538
+ A resume starts the agent in the directory it last ran in; for Claude Code
539
+ and OpenCode it also picks up the conversation that session last had
540
+ (`--resume <id>` / `--session <id>`, the id this machine read off the live
541
+ pane's own process — an id two panes both answered to is not kept). Claude Code starts fresh when that conversation is no longer on disk,
542
+ and the other agents always start a new one.
538
543
 
539
544
  Two details worth knowing:
540
545
 
@@ -646,9 +651,28 @@ transcript, then forgotten. A session that has been quiet for a long time keeps
646
651
  state while its transcript exists, so nothing already sent is sent again (an OpenCode
647
652
  session, which has no file to check, for 30 days).
648
653
 
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
654
+ A tmux name is used again when an agent starts in the same folder, so each
655
+ chunk also names its **run** — the tmux session it was recorded in (`runId`,
656
+ tmux's `session_created` in epoch seconds). The app uses it to keep one run's
657
+ conversation apart from the one before it under the same name; a chunk read
658
+ after a restart keeps the run it was recorded in. The listener's record of past
659
+ sessions keeps runs apart the same way: closing `hwip cc` and starting it again
660
+ in the same folder leaves the first run as a past session of its own (the
661
+ newest ten runs of a name are kept, 100 in all, and up to 50 are reported to
662
+ the phone). Each run is reported with the block of chunk numbers it took — the
663
+ first and, once it has ended, the last — kept in `run-seqs.json` next to the
664
+ registry, so the server reads one run's conversation from that range instead of
665
+ every chunk the name ever had. Deleting a past run from the phone forgets that
666
+ run on this machine too, even while a later run of the same name is going; the
667
+ run that is going is refused (`still_running`).
668
+
669
+ When a session waits for your approval, the listener also sends what it is waiting
670
+ for — the tool, the command or the diff, and the answers — so the app can show it
671
+ and answer. That covers Claude Code (a Bash command, a file edit or a new file),
672
+ Gemini CLI (its tool approvals), OpenCode (its permission request; the listener reads
673
+ the screen with its colours, since only a colour marks the chosen button) and pi
674
+ approval extensions that ask through `ctx.ui.select`. Codex's approval dialog is not
675
+ read yet. It is sealed the same
652
676
  way as a chunk, for the same pinned devices, and turned off by the same
653
677
  `--no-turn-history`; the server stores ciphertext only. Other dialogs (a plan, a
654
678
  question, a trust prompt) send nothing beyond the state. An answer the app sends is
@@ -1245,7 +1269,7 @@ hwip notify --title "Hello" --json
1245
1269
  | `verify` | Check installation health across detected agents (`--ping` for a live API call, with the key and API base every command resolves: `--key`, `--base-url`, then env, then config; `--json` prints the checks as one JSON object). Reports whether tmux is on PATH and whether the listener is running — both warnings, since notifications alone need neither. A settings file it cannot parse is a `!` line, not "not registered". Shows the last failed push, if any (see *Last push failure*). Also reports the resolved WebSocket URL **and where it came from** — `--ws-url`, config, the deprecated `HWIP_WS_URL`, or the built-in default — warns when the socket and the API base are on different stages, and warns when the running listener is on a different URL than the one that resolves now (it reads the config once, at startup) |
1246
1270
  | `check-update` | Check whether a newer Hwip version is on npm |
1247
1271
  | `notify` | Send a push notification |
1248
- | `ask` | Ask the phone a question and wait for the answer — for hooks, which cannot call the MCP `hwip_ask` tool. `--title` (required), `--body`, `--actions id:Label,…`, `--timeout <s>` (default 60), `--accepts-exit` for an agent's own ask (the phone may "send and exit", and the answer settles REMOTE like `hwip_ask`). Key and API base resolve like every command. Prints one JSON line — `{"answered":true,"actionId":…}` or `{"value":…}`, else `{"answered":false,"reason":"cancelled"\|"timeout"\|"error"}`; `"resumed":true` is added when the outcome is of a question an earlier run of the same command put up (see "A question cut short"). Exit 0 answered, 1 not. The question and the answer travel sealed, always (see Encryption); an answer that cannot be verified is an error, never an approval. With no app signed in to the account it prints `no app is signed in to this account` and exits 1 |
1272
+ | `ask` | Ask the phone a question and wait for the answer — for hooks, which cannot call the MCP `hwip_ask` tool. `--title` (required), `--body`, `--actions id:Label,…`, `--timeout <s>` (default 60), `--accepts-exit` for an agent's own ask (the phone may "send and exit" and attach files, and the answer settles REMOTE like `hwip_ask`; attached files are saved sealed-and-opened to `~/.hwip/attachments/<eventId>/`, their paths follow the text in `value` and are listed in `files`, and `missingFiles` names any not saved). Key and API base resolve like every command. Prints one JSON line — `{"answered":true,"actionId":…}` or `{"value":…}`, else `{"answered":false,"reason":"cancelled"\|"timeout"\|"error"}`; `"resumed":true` is added when the outcome is of a question an earlier run of the same command put up (see "A question cut short"). Exit 0 answered, 1 not. The question and the answer travel sealed, always (see Encryption); an answer that cannot be verified is an error, never an approval. With no app signed in to the account it prints `no app is signed in to this account` and exits 1 |
1249
1273
  | `ask-context [status\|on\|off\|show] [--global]` | The switch for the context a sealed question carries (a git diff against HEAD and the terminal's last lines, masked, sealed on every send — see Encryption). On unless turned off: `off` for the project the current directory is in (its repository root), `off --global` for every project, `on` to undo either; `status` says which applies. It is a state file under `$XDG_STATE_HOME/hwip` (`ask-context-off-<project hash>`, `ask-context-off-default`) and never leaves this machine; `HWIP_ASK_CONTEXT=0` switches it off for one run. A switch file that cannot be read counts as off. `show` prints one JSON line, `{"context": …}` — the summary and the masked original a question asked from the current directory would carry, `null` when there is nothing, with `"off"` naming why when the switch is off — and sends nothing (the MCP `hwip_ask` tool reads it) |
1250
1274
  | `file-view [status\|on\|off\|log] [--global]` | The switch for the phone browsing this computer's files (a session's repository — see Defense). On unless turned off: `off` for the project the current directory is in (its repository root), `off --global` for every project, `on` to undo either; `status` says which applies. A state file under `$XDG_STATE_HOME/hwip` (`file-view-off-<project hash>`, `file-view-off-default`) that never leaves this machine; a switch file that cannot be read counts as off. `log` prints the last 50 reads the phone made, from `~/.hwip/listener.log` (time, device, session, listing or file, path, bytes, secrets masked, result) |
1251
1275
  | `list` | List recent push notifications. An end-to-end encrypted push reads `[encrypted]` — this host cannot open it |
@@ -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
  {
@@ -0,0 +1,39 @@
1
+ import type { SealedFileMeta } from './crypto.js';
2
+ /**
3
+ * Files attached to a sealed answer of `hwip ask`. The answer's seal names each file (`SealedFileMeta`:
4
+ * its real name, type and size, matched by `iv`); the response's wire `files` carry the sealed bytes'
5
+ * `fileKey` and one wrapped key per device. The bytes are fetched and opened by the listener's
6
+ * `downloadSealedEach` — the same fetch and open as a sealed command's attachments, one file at a time — into
7
+ * `~/.hwip/attachments/<eventId>/`.
8
+ */
9
+ /** One wire file as the server keeps it for a sealed answer (hwip `server/src/functions/hooks.ts`). */
10
+ export interface AnswerWireFile {
11
+ fileKey: string;
12
+ fileName: string;
13
+ fileType?: string;
14
+ fileSize?: number;
15
+ iv: string;
16
+ deviceKeyMap: Record<string, string>;
17
+ }
18
+ export interface SavedAnswerFiles {
19
+ /** Absolute paths of the files saved. */
20
+ paths: string[];
21
+ /** One `<name>: <why>` per file the seal names that was not saved. */
22
+ missing: string[];
23
+ }
24
+ /** Who opens the files: this machine's slot, and the phone key the answer's seal was checked against. */
25
+ export interface AnswerFileOpener {
26
+ deviceId: string;
27
+ senderPublicKey: string;
28
+ }
29
+ /** The listener's per-file sealed download (`listener.ts` `downloadSealedEach`): each file saved or named with why. */
30
+ export type DownloadSealed = (dirId: string, files: AnswerWireFile[], sealed: AnswerFileOpener & {
31
+ files: SealedFileMeta[];
32
+ }) => Promise<SavedAnswerFiles>;
33
+ /**
34
+ * Save what the seal names and the wire delivered for this machine. A file the seal names with no wire
35
+ * file, or no slot here, is reported missing; the rest go to the listener's download in the seal's
36
+ * order, each saved or reported on its own. A download that throws as a whole reports each file with why.
37
+ */
38
+ export declare const saveAnswerFiles: (eventId: string, wire: unknown, metas: SealedFileMeta[], opener: AnswerFileOpener, download: DownloadSealed) => Promise<SavedAnswerFiles>;
39
+ //# sourceMappingURL=answer-files.d.ts.map
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.saveAnswerFiles = void 0;
4
+ /** A relay's wire file, parsed: an object with a fileKey, an iv and a key map. */
5
+ const isWireFile = (f) => {
6
+ if (typeof f !== 'object' || f === null)
7
+ return false;
8
+ const { fileKey, iv, deviceKeyMap } = f; // reading an unknown object's fields, each checked here
9
+ return typeof fileKey === 'string' && fileKey !== '' && typeof iv === 'string' && typeof deviceKeyMap === 'object' && deviceKeyMap !== null;
10
+ };
11
+ /**
12
+ * Save what the seal names and the wire delivered for this machine. A file the seal names with no wire
13
+ * file, or no slot here, is reported missing; the rest go to the listener's download in the seal's
14
+ * order, each saved or reported on its own. A download that throws as a whole reports each file with why.
15
+ */
16
+ const saveAnswerFiles = async (eventId, wire, metas, opener, download) => {
17
+ const given = Array.isArray(wire) ? wire.filter(isWireFile) : [];
18
+ const pairs = metas.map((meta) => ({
19
+ meta,
20
+ file: given.find((f) => f.iv === meta.iv && typeof f.deviceKeyMap[opener.deviceId] === 'string'),
21
+ }));
22
+ const missing = pairs.filter((p) => !p.file).map((p) => `${p.meta.name}: not delivered`);
23
+ const delivered = pairs.flatMap((p) => (p.file ? [{ meta: p.meta, file: p.file }] : []));
24
+ if (delivered.length === 0)
25
+ return { paths: [], missing };
26
+ try {
27
+ const saved = await download(eventId, delivered.map((d) => d.file), { ...opener, files: delivered.map((d) => d.meta) });
28
+ return { paths: saved.paths, missing: [...missing, ...saved.missing] };
29
+ }
30
+ catch (err) {
31
+ const why = err instanceof Error ? err.message : String(err);
32
+ return { paths: [], missing: [...missing, ...delivered.map((d) => `${d.meta.name}: ${why}`)] };
33
+ }
34
+ };
35
+ exports.saveAnswerFiles = saveAnswerFiles;
@@ -94,11 +94,15 @@ export type PreparedAsk = {
94
94
  why?: RegisterWhy;
95
95
  detail?: string;
96
96
  };
97
- export type OpenedAnswer = {
97
+ export type OpenedAnswer =
98
+ /** `files` comes with the key that sealed them, which opens each file's slot (answer-files.ts). */
99
+ {
98
100
  ok: true;
99
101
  actionId?: string;
100
102
  value?: string;
101
103
  exit?: true;
104
+ files?: SealedFileMeta[];
105
+ senderPublicKey?: string;
102
106
  } | {
103
107
  ok: false;
104
108
  cause: AskRefusal;
package/dist/ask-seal.js CHANGED
@@ -163,13 +163,16 @@ const openAnswer = async (response, askId, offered, deps) => {
163
163
  const { actionId, exit } = opened.answer;
164
164
  if (actionId !== undefined && (offered === undefined || !offered.includes(actionId)))
165
165
  return { ok: false, cause: 'unknown-action' };
166
- if (actionId === undefined && !opened.body && exit !== true)
166
+ // What the seal says each attached file is; the bytes come with the response's wire `files` (answer-files.ts).
167
+ const files = opened.files?.length ? opened.files : undefined;
168
+ if (actionId === undefined && !opened.body && exit !== true && !files)
167
169
  return { ok: false, cause: 'empty-answer' };
168
170
  return {
169
171
  ok: true,
170
172
  ...(actionId === undefined ? {} : { actionId }),
171
173
  ...(opened.body ? { value: opened.body } : {}),
172
174
  ...(exit === true ? { exit } : {}),
175
+ ...(files ? { files, senderPublicKey: sealed.senderPublicKey } : {}),
173
176
  };
174
177
  };
175
178
  exports.openAnswer = openAnswer;
package/dist/ask.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { type SealedFileMeta } from './crypto.js';
2
+ import { type AnswerFileOpener, type SavedAnswerFiles } from './answer-files.js';
1
3
  import { type AskSealDeps } from './ask-seal.js';
2
4
  import { type PendingAsks } from './ask-pending.js';
3
5
  /** Poll cadence: every second for the first 30 s, when a tap is most likely
@@ -33,6 +35,11 @@ export interface AskOptions {
33
35
  * stray `exitRemote` on its answer is dropped.
34
36
  */
35
37
  acceptsExit?: boolean;
38
+ /**
39
+ * Lets the phone attach files to a typed answer; they are saved to `~/.hwip/attachments/<eventId>/`
40
+ * and their paths follow the text. Set with `acceptsExit`: only an agent's own ask reads a reply.
41
+ */
42
+ acceptsFiles?: boolean;
36
43
  }
37
44
  /** Injected so the poll loop is testable without a clock or a network. */
38
45
  export interface AskDeps {
@@ -43,6 +50,8 @@ export interface AskDeps {
43
50
  seal: AskSealDeps;
44
51
  /** Where a question that went up is kept, so one cut short can be taken up again. Without it, every ask is a new question. */
45
52
  pending?: PendingAsks;
53
+ /** Saves the files a sealed answer names (answer-files.ts). Without it they are left on the server. */
54
+ saveFiles?: (eventId: string, wire: unknown, metas: SealedFileMeta[], opener: AnswerFileOpener) => Promise<SavedAnswerFiles>;
46
55
  }
47
56
  /** Why an ask ended unanswered: the user cancelled it on the phone, nobody
48
57
  * answered in time, or the server could not be reached or refused. */
@@ -54,6 +63,10 @@ export type AskOutcome = ({
54
63
  readonly answered: true;
55
64
  readonly value: string;
56
65
  readonly exitRemote?: true;
66
+ /** Where the answer's files were saved; their paths also follow the text in `value`. */
67
+ readonly files?: readonly string[];
68
+ /** `<name>: <why>` for each attached file that was not saved. */
69
+ readonly missingFiles?: readonly string[];
57
70
  } | {
58
71
  readonly answered: false;
59
72
  readonly reason: UnansweredReason;
package/dist/ask.js CHANGED
@@ -29,6 +29,7 @@ const insecure_url_js_1 = require("./insecure-url.js");
29
29
  const args_js_1 = require("./args.js");
30
30
  const api_js_1 = require("./api.js");
31
31
  const crypto_js_1 = require("./crypto.js");
32
+ const answer_files_js_1 = require("./answer-files.js");
32
33
  const listener_device_id_js_1 = require("./listener-device-id.js");
33
34
  const config_js_2 = require("./config.js");
34
35
  const self_register_js_1 = require("./self-register.js");
@@ -211,6 +212,7 @@ const trigger = async (deps, opts, deadline, prepared) => {
211
212
  agentDeviceId: opts.agentDeviceId,
212
213
  agentSessionName: opts.agentSessionName,
213
214
  ...(opts.acceptsExit ? { acceptsExit: true } : {}),
215
+ ...(opts.acceptsFiles ? { acceptsFiles: true } : {}),
214
216
  });
215
217
  const payload = { body, headers: { 'Idempotency-Key': (0, node_crypto_1.randomUUID)() } };
216
218
  let backoffs = 0;
@@ -238,18 +240,38 @@ const trigger = async (deps, opts, deadline, prepared) => {
238
240
  }
239
241
  }
240
242
  };
241
- /** What an opened answer means to the caller: the choice, or the text (with the exit when this ask offers it). */
242
- const sealedOutcome = (opened, acceptsExit) => {
243
+ /**
244
+ * What an opened answer means to the caller: the choice, or the text (with the exit when this ask offers
245
+ * it). Saved files' paths follow the text, so a reader of `value` alone still finds them.
246
+ */
247
+ const sealedOutcome = (opened, acceptsExit, saved) => {
243
248
  if (!opened.ok)
244
249
  return { answered: false, reason: 'error', error: (0, ask_seal_js_1.refusalMessage)(opened.cause) };
245
250
  if (opened.actionId !== undefined)
246
251
  return { answered: true, actionId: opened.actionId };
252
+ const value = [opened.value ?? '', ...(saved?.paths ?? [])].filter(Boolean).join('\n');
253
+ const files = {
254
+ ...(saved?.paths.length ? { files: saved.paths } : {}),
255
+ ...(saved?.missing.length ? { missingFiles: saved.missing } : {}),
256
+ };
247
257
  if (opened.exit === true && acceptsExit)
248
- return { answered: true, value: opened.value ?? '', exitRemote: true };
249
- if (opened.value)
250
- return { answered: true, value: opened.value };
258
+ return { answered: true, value, exitRemote: true, ...files };
259
+ if (value || saved)
260
+ return { answered: true, value, ...files };
251
261
  return { answered: false, reason: 'error', error: (0, ask_seal_js_1.refusalMessage)('empty-answer') };
252
262
  };
263
+ /** The answer's files, saved — when this ask took files, the answer names some, and this machine has its key. */
264
+ const saveFilesOf = async (deps, opts, eventId, response, opened) => {
265
+ if (!opened.ok || !opened.files || !opened.senderPublicKey)
266
+ return undefined;
267
+ // Not offered on this ask: said, not dropped.
268
+ if (!opts.acceptsFiles || !deps.saveFiles)
269
+ return { paths: [], missing: opened.files.map((m) => `${m.name}: this ask does not take files`) };
270
+ const deviceId = deps.seal.self()?.deviceId;
271
+ if (!deviceId)
272
+ return { paths: [], missing: opened.files.map((m) => `${m.name}: this machine has no key`) };
273
+ return deps.saveFiles(eventId, response?.files, opened.files, { deviceId, senderPublicKey: opened.senderPublicKey });
274
+ };
253
275
  /**
254
276
  * Read the question's answer until there is one, the question ends without, or `until` passes — once
255
277
  * at least, whatever the clock says: the answer may be there already.
@@ -277,7 +299,8 @@ const awaitAnswer = async (deps, opts, asked, until, unconfirmed) => {
277
299
  lastError = new Error((0, ask_seal_js_1.refusalMessage)(opened.cause));
278
300
  }
279
301
  else {
280
- return { outcome: sealedOutcome(opened, opts.acceptsExit === true), end: mayPass ? 'open' : 'over' };
302
+ const saved = await saveFilesOf(deps, opts, asked.eventId, event.data.response, opened);
303
+ return { outcome: sealedOutcome(opened, opts.acceptsExit === true, saved), end: mayPass ? 'open' : 'over' };
281
304
  }
282
305
  }
283
306
  if (event.data?.status === 'cancelled')
@@ -478,6 +501,13 @@ const liveDeps = (account) => ({
478
501
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
479
502
  seal: (0, exports.liveSealDeps)(account.apiKey, account.baseUrl, account.deadline),
480
503
  pending: (0, ask_pending_js_1.pendingAsks)((0, gate_js_1.stateDir)()),
504
+ saveFiles: async (eventId, wire, metas, opener) => {
505
+ // The listener's download, borrowed: its log is the daemon's stdout, and this command's stdout is one JSON line.
506
+ const { downloadSealedEach } = await import('./listener.js');
507
+ const { silenceListenerLog } = await import('./listener-log.js');
508
+ silenceListenerLog();
509
+ return (0, answer_files_js_1.saveAnswerFiles)(eventId, wire, metas, opener, (dirId, files, sealed) => downloadSealedEach(dirId, files, { apiKey: account.apiKey, baseUrl: account.baseUrl }, sealed));
510
+ },
481
511
  });
482
512
  exports.liveDeps = liveDeps;
483
513
  /**
@@ -536,6 +566,7 @@ const handleAsk = async (args) => {
536
566
  timeoutSeconds: windowSeconds,
537
567
  ...((0, agent_session_js_1.agentSessionContext)() ?? {}),
538
568
  acceptsExit,
569
+ acceptsFiles: acceptsExit,
539
570
  }, (0, exports.liveDeps)({ apiKey, baseUrl: baseUrl.replace(/\/+$/, ''), deadline: Date.now() + windowSeconds * 1000 }));
540
571
  // The agent's own ask settles REMOTE as mcp-server's hwip_ask does and
541
572
  // reports it in the same `hwipState` field; an approval gate leaves the
@@ -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;