@hwip/cli 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -4
- package/dist/agent-rules.default.d.ts +1 -1
- package/dist/agent-rules.default.js +34 -21
- package/dist/answer-files.d.ts +39 -0
- package/dist/answer-files.js +35 -0
- package/dist/ask-seal.d.ts +5 -1
- package/dist/ask-seal.js +4 -1
- package/dist/ask.d.ts +13 -0
- package/dist/ask.js +37 -6
- package/dist/blocked-prompt-seal.d.ts +6 -2
- package/dist/blocked-prompt-seal.js +7 -4
- package/dist/blocked-prompt.d.ts +7 -2
- package/dist/blocked-prompt.js +361 -24
- package/dist/config.d.ts +14 -0
- package/dist/config.js +23 -1
- package/dist/file-crypto.js +7 -1
- package/dist/listener.d.ts +50 -2
- package/dist/listener.js +166 -36
- package/dist/remote-agents.d.ts +22 -0
- package/dist/remote-agents.js +22 -5
- package/dist/run-seqs.d.ts +28 -0
- package/dist/run-seqs.js +79 -0
- package/dist/session-registry.d.ts +34 -4
- package/dist/session-registry.js +50 -14
- package/dist/templates.js +16 -5
- package/dist/tmux-liveness.d.ts +7 -0
- package/dist/tmux-liveness.js +22 -1
- package/dist/turn-recorder-wire.js +1 -1
- package/dist/turn-recorder.d.ts +8 -0
- package/dist/turn-recorder.js +10 -2
- package/dist/wrapper.d.ts +12 -4
- package/dist/wrapper.js +47 -6
- package/package.json +16 -2
package/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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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.
|
|
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
|
{
|
|
@@ -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;
|
package/dist/ask-seal.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
242
|
-
|
|
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
|
|
249
|
-
if (
|
|
250
|
-
return { answered: true, 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
|
-
|
|
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
|
-
/**
|
|
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;
|