@hwip/cli 0.0.1 → 0.1.0
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/LICENSE +190 -0
- package/NOTICE +11 -0
- package/README.md +1979 -0
- package/dist/agent-list.d.ts +76 -0
- package/dist/agent-list.js +102 -0
- package/dist/agent-read.d.ts +105 -0
- package/dist/agent-read.js +309 -0
- package/dist/agent-rules-fetch.d.ts +51 -0
- package/dist/agent-rules-fetch.js +275 -0
- package/dist/agent-rules.default.d.ts +30 -0
- package/dist/agent-rules.default.js +292 -0
- package/dist/agent-session.d.ts +23 -0
- package/dist/agent-session.js +44 -0
- package/dist/agent-state.d.ts +84 -0
- package/dist/agent-state.js +188 -0
- package/dist/agent-target.d.ts +60 -0
- package/dist/agent-target.js +81 -0
- package/dist/agents.d.ts +31 -0
- package/dist/agents.js +75 -0
- package/dist/api.d.ts +28 -0
- package/dist/api.js +76 -0
- package/dist/args.d.ts +74 -0
- package/dist/args.js +254 -0
- package/dist/ask-context-attach.d.ts +73 -0
- package/dist/ask-context-attach.js +151 -0
- package/dist/ask-context-screen.d.ts +21 -0
- package/dist/ask-context-screen.js +49 -0
- package/dist/ask-context-switch.d.ts +31 -0
- package/dist/ask-context-switch.js +74 -0
- package/dist/ask-context.d.ts +84 -0
- package/dist/ask-context.js +172 -0
- package/dist/ask-pending.d.ts +47 -0
- package/dist/ask-pending.js +221 -0
- package/dist/ask-seal.d.ts +120 -0
- package/dist/ask-seal.js +179 -0
- package/dist/ask.d.ts +107 -0
- package/dist/ask.js +551 -0
- package/dist/blocked-prompt-seal.d.ts +35 -0
- package/dist/blocked-prompt-seal.js +89 -0
- package/dist/blocked-prompt.d.ts +57 -0
- package/dist/blocked-prompt.js +181 -0
- package/dist/channel.d.ts +67 -0
- package/dist/channel.js +136 -0
- package/dist/check-update.d.ts +28 -0
- package/dist/check-update.js +153 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +813 -0
- package/dist/codex-config.d.ts +24 -0
- package/dist/codex-config.js +169 -0
- package/dist/codex-transcript.d.ts +59 -0
- package/dist/codex-transcript.js +373 -0
- package/dist/command-refusal.d.ts +56 -0
- package/dist/command-refusal.js +127 -0
- package/dist/command-scan.d.ts +46 -0
- package/dist/command-scan.js +154 -0
- package/dist/command-seal.d.ts +57 -0
- package/dist/command-seal.js +88 -0
- package/dist/command-seq.d.ts +30 -0
- package/dist/command-seq.js +150 -0
- package/dist/config.d.ts +156 -0
- package/dist/config.js +392 -0
- package/dist/core-sections.d.ts +18 -0
- package/dist/core-sections.js +28 -0
- package/dist/crypto.d.ts +278 -0
- package/dist/crypto.js +698 -0
- package/dist/desktop-notify.d.ts +33 -0
- package/dist/desktop-notify.js +49 -0
- package/dist/device-directory.d.ts +36 -0
- package/dist/device-directory.js +97 -0
- package/dist/device-presence.d.ts +17 -0
- package/dist/device-presence.js +62 -0
- package/dist/downloads.d.ts +29 -0
- package/dist/downloads.js +106 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +28 -0
- package/dist/file-crypto.d.ts +94 -0
- package/dist/file-crypto.js +272 -0
- package/dist/file-view-budget.d.ts +15 -0
- package/dist/file-view-budget.js +34 -0
- package/dist/file-view-switch.d.ts +28 -0
- package/dist/file-view-switch.js +122 -0
- package/dist/file-view.d.ts +108 -0
- package/dist/file-view.js +327 -0
- package/dist/gate.d.ts +141 -0
- package/dist/gate.js +336 -0
- package/dist/gemini-transcript.d.ts +32 -0
- package/dist/gemini-transcript.js +189 -0
- package/dist/http-failure.d.ts +98 -0
- package/dist/http-failure.js +349 -0
- package/dist/hwip-core.generated.d.ts +7 -0
- package/dist/hwip-core.generated.js +14 -0
- package/dist/hwip-hook.d.ts +77 -0
- package/dist/hwip-hook.js +340 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/input-sequencer.d.ts +65 -0
- package/dist/input-sequencer.js +142 -0
- package/dist/insecure-url.d.ts +16 -0
- package/dist/insecure-url.js +76 -0
- package/dist/installer.d.ts +96 -0
- package/dist/installer.js +850 -0
- package/dist/inventory-offload.d.ts +49 -0
- package/dist/inventory-offload.js +209 -0
- package/dist/inventory-worker.d.ts +2 -0
- package/dist/inventory-worker.js +27 -0
- package/dist/jsonc-doc.d.ts +106 -0
- package/dist/jsonc-doc.js +354 -0
- package/dist/keep-awake.d.ts +32 -0
- package/dist/keep-awake.js +46 -0
- package/dist/key-changes.d.ts +48 -0
- package/dist/key-changes.js +140 -0
- package/dist/lan-auth.d.ts +140 -0
- package/dist/lan-auth.js +217 -0
- package/dist/lan-endpoint.d.ts +41 -0
- package/dist/lan-endpoint.js +115 -0
- package/dist/lan-receiver.d.ts +122 -0
- package/dist/lan-receiver.js +510 -0
- package/dist/lan-sender.d.ts +76 -0
- package/dist/lan-sender.js +184 -0
- package/dist/lan-transfer.d.ts +36 -0
- package/dist/lan-transfer.js +54 -0
- package/dist/listener-device-id.d.ts +20 -0
- package/dist/listener-device-id.js +133 -0
- package/dist/listener-log.d.ts +4 -0
- package/dist/listener-log.js +23 -0
- package/dist/listener-process.d.ts +200 -0
- package/dist/listener-process.js +470 -0
- package/dist/listener-service.d.ts +225 -0
- package/dist/listener-service.js +682 -0
- package/dist/listener-worktree-cleanup.d.ts +27 -0
- package/dist/listener-worktree-cleanup.js +62 -0
- package/dist/listener.d.ts +1325 -0
- package/dist/listener.js +5680 -0
- package/dist/login-address.d.ts +25 -0
- package/dist/login-address.js +97 -0
- package/dist/login-page.d.ts +29 -0
- package/dist/login-page.js +93 -0
- package/dist/login.d.ts +98 -0
- package/dist/login.js +388 -0
- package/dist/mcp-command.d.ts +45 -0
- package/dist/mcp-command.js +50 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.js +29 -0
- package/dist/node-support.d.ts +19 -0
- package/dist/node-support.js +38 -0
- package/dist/notify-failure.d.ts +57 -0
- package/dist/notify-failure.js +150 -0
- package/dist/opencode-transcript.d.ts +139 -0
- package/dist/opencode-transcript.js +390 -0
- package/dist/optional-features.d.ts +6 -0
- package/dist/optional-features.js +19 -0
- package/dist/pane-fit.d.ts +40 -0
- package/dist/pane-fit.js +119 -0
- package/dist/peer-key.d.ts +33 -0
- package/dist/peer-key.js +34 -0
- package/dist/pi-transcript.d.ts +26 -0
- package/dist/pi-transcript.js +255 -0
- package/dist/presence.d.ts +14 -0
- package/dist/presence.js +124 -0
- package/dist/private-files.d.ts +51 -0
- package/dist/private-files.js +218 -0
- package/dist/project-switch.d.ts +19 -0
- package/dist/project-switch.js +60 -0
- package/dist/redact.d.ts +10 -0
- package/dist/redact.js +21 -0
- package/dist/remote-agents.d.ts +410 -0
- package/dist/remote-agents.js +1218 -0
- package/dist/remote-hook.d.ts +12 -0
- package/dist/remote-hook.js +196 -0
- package/dist/replace-file.d.ts +50 -0
- package/dist/replace-file.js +328 -0
- package/dist/schedule-command.d.ts +35 -0
- package/dist/schedule-command.js +260 -0
- package/dist/schedule-launch.d.ts +15 -0
- package/dist/schedule-launch.js +24 -0
- package/dist/schedule-ready.d.ts +44 -0
- package/dist/schedule-ready.js +71 -0
- package/dist/schedule-remote.d.ts +77 -0
- package/dist/schedule-remote.js +167 -0
- package/dist/schedule-runner.d.ts +89 -0
- package/dist/schedule-runner.js +452 -0
- package/dist/schedule.d.ts +149 -0
- package/dist/schedule.js +428 -0
- package/dist/secret-mask.d.ts +29 -0
- package/dist/secret-mask.js +245 -0
- package/dist/self-register.d.ts +45 -0
- package/dist/self-register.js +67 -0
- package/dist/send.d.ts +56 -0
- package/dist/send.js +104 -0
- package/dist/session-diff.d.ts +161 -0
- package/dist/session-diff.js +445 -0
- package/dist/session-registry.d.ts +143 -0
- package/dist/session-registry.js +216 -0
- package/dist/sessions-report.d.ts +59 -0
- package/dist/sessions-report.js +99 -0
- package/dist/shell-audit.d.ts +34 -0
- package/dist/shell-audit.js +47 -0
- package/dist/subagent-transcripts.d.ts +167 -0
- package/dist/subagent-transcripts.js +437 -0
- package/dist/subscriber-trust.d.ts +128 -0
- package/dist/subscriber-trust.js +397 -0
- package/dist/templates.d.ts +105 -0
- package/dist/templates.js +871 -0
- package/dist/tmux-install.d.ts +44 -0
- package/dist/tmux-install.js +65 -0
- package/dist/tmux-liveness.d.ts +45 -0
- package/dist/tmux-liveness.js +71 -0
- package/dist/tmux-pane.d.ts +55 -0
- package/dist/tmux-pane.js +126 -0
- package/dist/tmux-socket.d.ts +20 -0
- package/dist/tmux-socket.js +292 -0
- package/dist/todo-fold.d.ts +31 -0
- package/dist/todo-fold.js +102 -0
- package/dist/transcript-tail.d.ts +312 -0
- package/dist/transcript-tail.js +718 -0
- package/dist/trust-command.d.ts +49 -0
- package/dist/trust-command.js +200 -0
- package/dist/trust-lock.d.ts +26 -0
- package/dist/trust-lock.js +94 -0
- package/dist/trust-pins.d.ts +37 -0
- package/dist/trust-pins.js +82 -0
- package/dist/turn-recorder-wire.d.ts +70 -0
- package/dist/turn-recorder-wire.js +165 -0
- package/dist/turn-recorder.d.ts +190 -0
- package/dist/turn-recorder.js +695 -0
- package/dist/turn-ring.d.ts +93 -0
- package/dist/turn-ring.js +418 -0
- package/dist/turn-watch.d.ts +177 -0
- package/dist/turn-watch.js +630 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.js +2 -0
- package/dist/uninstall.d.ts +55 -0
- package/dist/uninstall.js +507 -0
- package/dist/usage.d.ts +17 -0
- package/dist/usage.js +421 -0
- package/dist/verify.d.ts +47 -0
- package/dist/verify.js +396 -0
- package/dist/worktree.d.ts +144 -0
- package/dist/worktree.js +310 -0
- package/dist/wrapper.d.ts +254 -0
- package/dist/wrapper.js +704 -0
- package/dist/ws-url.d.ts +109 -0
- package/dist/ws-url.js +202 -0
- package/package.json +78 -10
- package/bin/hwip.js +0 -3
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hwip ask` (F1): the question goes to the phone inside a push-v1 envelope and the
|
|
3
|
+
* answer comes back in one, so the server relays both and reads neither.
|
|
4
|
+
*
|
|
5
|
+
* Every question is sealed. When that cannot be done — no keys, no device list, no phone app signed in,
|
|
6
|
+
* no device with a key — it is refused, never sent in the clear. Who reads it is the account's device list;
|
|
7
|
+
* each device on it is pinned on the way (`trust-pins.ts`), and a key that changed replaces its pin with one warning.
|
|
8
|
+
*
|
|
9
|
+
* What the server still sees of a sealed question: each choice's `id`, the timeout, the session routing fields and which device answered,
|
|
10
|
+
* and that it has one attachment of a fixed name, type and size (`ask-context-attach.ts`) — or none, when that attachment could not be stored or would take the envelope over the server's limit.
|
|
11
|
+
* Not the title, the body, the wording of the choices, the answer, or whether the attachment holds anything.
|
|
12
|
+
*
|
|
13
|
+
* Nothing here logs or returns a decrypt error's message: the plaintext of an
|
|
14
|
+
* authenticated envelope can end up in one, and a refusal says only which step failed.
|
|
15
|
+
*/
|
|
16
|
+
import { decryptPushBodyForDevice, type DeviceRecipient, type PushBodyInput, type SealedAskContext, type SealedFileMeta } from './crypto.js';
|
|
17
|
+
import { type Register, type RegisterWhy } from './self-register.js';
|
|
18
|
+
export interface AskQuestion {
|
|
19
|
+
title: string;
|
|
20
|
+
body?: string;
|
|
21
|
+
actions?: {
|
|
22
|
+
id: string;
|
|
23
|
+
label: string;
|
|
24
|
+
}[];
|
|
25
|
+
}
|
|
26
|
+
/** A sealed envelope as the trigger and the respond route carry it. */
|
|
27
|
+
export interface SealedFields {
|
|
28
|
+
body: string;
|
|
29
|
+
senderPublicKey: string;
|
|
30
|
+
deviceKeyMap: Record<string, string>;
|
|
31
|
+
}
|
|
32
|
+
/** What `GET /devices` says of a device that the seal needs: `type` is the kind of client (`ios`, `android`, `cli`, …). */
|
|
33
|
+
type DeviceEntry = {
|
|
34
|
+
deviceId: string;
|
|
35
|
+
publicKey?: string;
|
|
36
|
+
type?: string;
|
|
37
|
+
};
|
|
38
|
+
/** One sealed attachment as the trigger carries it: everything in it but the wrapped keys is in the server's view. */
|
|
39
|
+
export interface AskWireFile {
|
|
40
|
+
fileKey: string;
|
|
41
|
+
fileName: string;
|
|
42
|
+
fileType: string;
|
|
43
|
+
fileSize: number;
|
|
44
|
+
iv: string;
|
|
45
|
+
deviceKeyMap: Record<string, string>;
|
|
46
|
+
}
|
|
47
|
+
/** What a sealed question carries of its session (F3, `ask-context-attach.ts`). */
|
|
48
|
+
export interface AskAttachment {
|
|
49
|
+
/** The summary for the sealed body; left out when there is no context (the attachment is there all the same). */
|
|
50
|
+
context?: SealedAskContext;
|
|
51
|
+
/** How the sealed body names the attachment. */
|
|
52
|
+
meta: SealedFileMeta;
|
|
53
|
+
file: AskWireFile;
|
|
54
|
+
}
|
|
55
|
+
export interface AskSealDeps {
|
|
56
|
+
/** Load this machine's device keys; throws when they cannot be had. */
|
|
57
|
+
loadKeys: () => Promise<void>;
|
|
58
|
+
/** This machine as a device of the account, once its keys are loaded. */
|
|
59
|
+
self: () => {
|
|
60
|
+
deviceId: string;
|
|
61
|
+
publicKey: string;
|
|
62
|
+
} | null;
|
|
63
|
+
/** `GET /devices`; throws when the list cannot be fetched. */
|
|
64
|
+
listDevices: () => Promise<DeviceEntry[]>;
|
|
65
|
+
/** Put this machine's key on the account's device list when the list lacks it (`self-register.ts`). */
|
|
66
|
+
register: Register;
|
|
67
|
+
/** The listener's state file, where devices are pinned. */
|
|
68
|
+
trustPath: string;
|
|
69
|
+
/** One line for a person: a pin replaced because the device lists another key, this machine's key not registered, or — from `ask.ts` — a question that went up unconfirmed or stays on record. The caller adds its prefix. */
|
|
70
|
+
warn?: (line: string) => void;
|
|
71
|
+
seal?: (content: PushBodyInput, recipients: DeviceRecipient[]) => Promise<SealedFields & {
|
|
72
|
+
isEncrypted: true;
|
|
73
|
+
}>;
|
|
74
|
+
open?: typeof decryptPushBodyForDevice;
|
|
75
|
+
/** A fresh 128-bit id, URL-safe. */
|
|
76
|
+
newAskId?: () => string;
|
|
77
|
+
/**
|
|
78
|
+
* Uploads the context attachment sealed for `recipients`. Called only for a question that is being sealed,
|
|
79
|
+
* once who may read it is known; when it throws, the question goes out without an attachment.
|
|
80
|
+
*/
|
|
81
|
+
attach?: (recipients: DeviceRecipient[]) => Promise<AskAttachment>;
|
|
82
|
+
}
|
|
83
|
+
export type AskRefusal = 'keystore' | 'devices' | 'no-app' | 'self-unlisted' | 'no-recipients' | 'too-large' | 'encrypt' | 'not-sealed' | 'peer-unlisted' | 'peer-unusable' | 'sender-mismatch' | 'no-slot' | 'unreadable' | 'wrong-labels' | 'unknown-action' | 'empty-answer';
|
|
84
|
+
export declare const refusalMessage: (cause: AskRefusal, why?: RegisterWhy, detail?: string) => string;
|
|
85
|
+
export type PreparedAsk = {
|
|
86
|
+
kind: 'sealed';
|
|
87
|
+
askId: string;
|
|
88
|
+
fields: SealedFields;
|
|
89
|
+
offered: string[] | undefined;
|
|
90
|
+
files?: AskWireFile[];
|
|
91
|
+
} | {
|
|
92
|
+
kind: 'refused';
|
|
93
|
+
cause: AskRefusal;
|
|
94
|
+
why?: RegisterWhy;
|
|
95
|
+
detail?: string;
|
|
96
|
+
};
|
|
97
|
+
export type OpenedAnswer = {
|
|
98
|
+
ok: true;
|
|
99
|
+
actionId?: string;
|
|
100
|
+
value?: string;
|
|
101
|
+
exit?: true;
|
|
102
|
+
} | {
|
|
103
|
+
ok: false;
|
|
104
|
+
cause: AskRefusal;
|
|
105
|
+
};
|
|
106
|
+
export declare const prepareAsk: (question: AskQuestion, deps: AskSealDeps) => Promise<PreparedAsk>;
|
|
107
|
+
/**
|
|
108
|
+
* Open the answer the phone sealed. It counts only when every step holds:
|
|
109
|
+
* the answering device's key is believed (`peerKeyOf`: the account's device list, never
|
|
110
|
+
* this machine's own key), the envelope was
|
|
111
|
+
* sealed with exactly that key, it opens with this machine's slot, and it is
|
|
112
|
+
* an `answer` for this question's id — and not a question or command label,
|
|
113
|
+
* which would be the question envelope this machine sent, played back.
|
|
114
|
+
*/
|
|
115
|
+
export declare const openAnswer: (response: {
|
|
116
|
+
sealed?: unknown;
|
|
117
|
+
respondedDeviceId?: unknown;
|
|
118
|
+
} | undefined, askId: string, offered: string[] | undefined, deps: AskSealDeps) => Promise<OpenedAnswer>;
|
|
119
|
+
export {};
|
|
120
|
+
//# sourceMappingURL=ask-seal.d.ts.map
|
package/dist/ask-seal.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `hwip ask` (F1): the question goes to the phone inside a push-v1 envelope and the
|
|
4
|
+
* answer comes back in one, so the server relays both and reads neither.
|
|
5
|
+
*
|
|
6
|
+
* Every question is sealed. When that cannot be done — no keys, no device list, no phone app signed in,
|
|
7
|
+
* no device with a key — it is refused, never sent in the clear. Who reads it is the account's device list;
|
|
8
|
+
* each device on it is pinned on the way (`trust-pins.ts`), and a key that changed replaces its pin with one warning.
|
|
9
|
+
*
|
|
10
|
+
* What the server still sees of a sealed question: each choice's `id`, the timeout, the session routing fields and which device answered,
|
|
11
|
+
* and that it has one attachment of a fixed name, type and size (`ask-context-attach.ts`) — or none, when that attachment could not be stored or would take the envelope over the server's limit.
|
|
12
|
+
* Not the title, the body, the wording of the choices, the answer, or whether the attachment holds anything.
|
|
13
|
+
*
|
|
14
|
+
* Nothing here logs or returns a decrypt error's message: the plaintext of an
|
|
15
|
+
* authenticated envelope can end up in one, and a refusal says only which step failed.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.openAnswer = exports.prepareAsk = exports.refusalMessage = void 0;
|
|
19
|
+
const node_crypto_1 = require("node:crypto");
|
|
20
|
+
const crypto_js_1 = require("./crypto.js");
|
|
21
|
+
const peer_key_js_1 = require("./peer-key.js");
|
|
22
|
+
const trust_pins_js_1 = require("./trust-pins.js");
|
|
23
|
+
const self_register_js_1 = require("./self-register.js");
|
|
24
|
+
const http_failure_js_1 = require("./http-failure.js");
|
|
25
|
+
/** The longest envelope the server takes as a sealed question's `body` (hwip `server/src/lib/push-input.ts` MAX_ASK_BODY_BYTES). */
|
|
26
|
+
const ASK_BODY_MAX_BYTES = 48 * 1024;
|
|
27
|
+
const REFUSAL_TEXT = {
|
|
28
|
+
'keystore': 'this machine\'s encryption keys did not load; the question was not sent',
|
|
29
|
+
'devices': 'the device list could not be fetched; the question was not sent',
|
|
30
|
+
'no-app': 'no app is signed in to this account: sign in to the Hwip app on an iPhone or Android phone, then send again; the question was not sent',
|
|
31
|
+
'self-unlisted': (0, self_register_js_1.unregisteredText)('question'),
|
|
32
|
+
'no-recipients': 'no device has a key to seal the question for; the question was not sent',
|
|
33
|
+
'too-large': 'a choice id is over 64 characters, a choice label over 100, or there are more than 10 choices; shorten them (the question was not sent)',
|
|
34
|
+
'encrypt': 'sealing the question failed; the question was not sent',
|
|
35
|
+
'not-sealed': 'the answer to a sealed question was not sealed; ignored',
|
|
36
|
+
'peer-unlisted': 'the answer came from a device the account does not list; ignored',
|
|
37
|
+
'peer-unusable': 'the answering device could not be checked (device list); ignored',
|
|
38
|
+
'sender-mismatch': 'the answer was sealed with a key other than the answering device\'s; ignored',
|
|
39
|
+
'no-slot': 'the answer was not sealed for this machine; ignored',
|
|
40
|
+
'unreadable': 'the answer could not be opened; ignored',
|
|
41
|
+
'wrong-labels': 'the answer does not belong to this question; ignored',
|
|
42
|
+
'unknown-action': 'the answer names a choice that was not offered; ignored',
|
|
43
|
+
'empty-answer': 'the answer holds no choice and no text; ignored',
|
|
44
|
+
};
|
|
45
|
+
const refusalMessage = (cause, why, detail) => detail ?? `sealed ask: ${cause === 'self-unlisted' ? (0, self_register_js_1.unregisteredText)('question', why) : REFUSAL_TEXT[cause]}`;
|
|
46
|
+
exports.refusalMessage = refusalMessage;
|
|
47
|
+
const defaultAskId = () => (0, node_crypto_1.randomBytes)(16).toString('base64url');
|
|
48
|
+
const prepareAsk = async (question, deps) => {
|
|
49
|
+
try {
|
|
50
|
+
await deps.loadKeys();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return { kind: 'refused', cause: 'keystore' };
|
|
54
|
+
}
|
|
55
|
+
const self = deps.self();
|
|
56
|
+
if (!self)
|
|
57
|
+
return { kind: 'refused', cause: 'keystore' };
|
|
58
|
+
let devices;
|
|
59
|
+
try {
|
|
60
|
+
devices = await deps.listDevices();
|
|
61
|
+
if (!Array.isArray(devices))
|
|
62
|
+
throw new Error('not a list');
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
// A failure from the configuration (bad key, bad base URL) is said as it is: retrying the
|
|
66
|
+
// question or checking the devices would not fix it, and the generic "device list" line hides it.
|
|
67
|
+
if ((0, http_failure_js_1.isConfigFailure)(err))
|
|
68
|
+
return { kind: 'refused', cause: 'devices', detail: err instanceof Error ? err.message : String(err) };
|
|
69
|
+
return { kind: 'refused', cause: 'devices' };
|
|
70
|
+
}
|
|
71
|
+
// A question nobody can answer on a phone is not sent.
|
|
72
|
+
if (!devices.some(crypto_js_1.isAppDevice))
|
|
73
|
+
return { kind: 'refused', cause: 'no-app' };
|
|
74
|
+
// The phone seals its answer for the computer named in the question, by the key the same list holds for it: one
|
|
75
|
+
// missing or under another key is put there first.
|
|
76
|
+
const listing = await (0, self_register_js_1.ensureSelfListed)(devices, self, deps.register);
|
|
77
|
+
if (!listing.ok)
|
|
78
|
+
return { kind: 'refused', cause: 'self-unlisted', why: listing.why };
|
|
79
|
+
devices = listing.devices;
|
|
80
|
+
// Every device that lists a key is pinned and reads the question; a pin never takes a reader away.
|
|
81
|
+
(0, trust_pins_js_1.observeDevices)(deps.trustPath, devices, { selfId: self.deviceId, warn: deps.warn ?? (() => undefined) });
|
|
82
|
+
const recipients = (0, crypto_js_1.selectRecipients)(devices).filter((d) => d.deviceId !== self.deviceId);
|
|
83
|
+
if (recipients.length === 0)
|
|
84
|
+
return { kind: 'refused', cause: 'no-recipients' };
|
|
85
|
+
// The context never stops a question: an approval gate that cannot attach still has to ask (the live `attach` says why on stderr).
|
|
86
|
+
const attachment = deps.attach ? await deps.attach(recipients).catch(() => null) : null;
|
|
87
|
+
const askId = (deps.newAskId ?? defaultAskId)();
|
|
88
|
+
const actions = question.actions;
|
|
89
|
+
const sealWith = (context, attached = attachment) => (deps.seal ?? crypto_js_1.encryptPushBodyForDevices)({
|
|
90
|
+
title: question.title,
|
|
91
|
+
body: question.body,
|
|
92
|
+
ask: { id: askId, device: self.deviceId, ...(actions ? { actions } : {}) },
|
|
93
|
+
...(attached ? { files: [attached.meta] } : {}),
|
|
94
|
+
...(context ? { context } : {}),
|
|
95
|
+
}, recipients);
|
|
96
|
+
try {
|
|
97
|
+
let sealed = await sealWith(attachment?.context);
|
|
98
|
+
// The server refuses a longer envelope whole: the summary goes before the question does, then the attachment's name.
|
|
99
|
+
let attached = attachment;
|
|
100
|
+
const over = () => attached !== null && Buffer.byteLength(sealed.body) > ASK_BODY_MAX_BYTES;
|
|
101
|
+
if (over() && attached?.context)
|
|
102
|
+
sealed = await sealWith(undefined);
|
|
103
|
+
if (over()) {
|
|
104
|
+
attached = null;
|
|
105
|
+
sealed = await sealWith(undefined, null);
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
kind: 'sealed',
|
|
109
|
+
askId,
|
|
110
|
+
fields: { body: sealed.body, senderPublicKey: sealed.senderPublicKey, deviceKeyMap: sealed.deviceKeyMap },
|
|
111
|
+
offered: actions?.map((a) => a.id),
|
|
112
|
+
...(attached ? { files: [attached.file] } : {}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
// Only crypto.ts `assertAskWithinLimits` carries this code, for the choices alone; a test seals an over-limit choice for real.
|
|
117
|
+
if (err instanceof Error && err.code === crypto_js_1.ASK_CHOICES_CODE)
|
|
118
|
+
return { kind: 'refused', cause: 'too-large' };
|
|
119
|
+
return { kind: 'refused', cause: 'encrypt' };
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
exports.prepareAsk = prepareAsk;
|
|
123
|
+
const PEER_REFUSALS = {
|
|
124
|
+
unlisted: 'peer-unlisted',
|
|
125
|
+
devices_unavailable: 'peer-unusable',
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Open the answer the phone sealed. It counts only when every step holds:
|
|
129
|
+
* the answering device's key is believed (`peerKeyOf`: the account's device list, never
|
|
130
|
+
* this machine's own key), the envelope was
|
|
131
|
+
* sealed with exactly that key, it opens with this machine's slot, and it is
|
|
132
|
+
* an `answer` for this question's id — and not a question or command label,
|
|
133
|
+
* which would be the question envelope this machine sent, played back.
|
|
134
|
+
*/
|
|
135
|
+
const openAnswer = async (response, askId, offered, deps) => {
|
|
136
|
+
const sealed = response?.sealed;
|
|
137
|
+
const from = response?.respondedDeviceId;
|
|
138
|
+
if (!isSealedFields(sealed) || typeof from !== 'string')
|
|
139
|
+
return { ok: false, cause: 'not-sealed' };
|
|
140
|
+
const peer = await (0, peer_key_js_1.peerKeyOf)(from, {
|
|
141
|
+
registeredKey: async (id) => (await deps.listDevices()).find((d) => d.deviceId === id)?.publicKey ?? null,
|
|
142
|
+
trustPath: deps.trustPath,
|
|
143
|
+
selfId: deps.self()?.deviceId,
|
|
144
|
+
warn: deps.warn,
|
|
145
|
+
});
|
|
146
|
+
if ('refused' in peer)
|
|
147
|
+
return { ok: false, cause: PEER_REFUSALS[peer.refused] };
|
|
148
|
+
if (!(0, crypto_js_1.samePublicKey)(peer.key, sealed.senderPublicKey))
|
|
149
|
+
return { ok: false, cause: 'sender-mismatch' };
|
|
150
|
+
const self = deps.self();
|
|
151
|
+
const slot = self ? sealed.deviceKeyMap[self.deviceId] : undefined;
|
|
152
|
+
if (typeof slot !== 'string')
|
|
153
|
+
return { ok: false, cause: 'no-slot' };
|
|
154
|
+
let opened;
|
|
155
|
+
try {
|
|
156
|
+
opened = await (deps.open ?? crypto_js_1.decryptPushBodyForDevice)(sealed.body, slot, sealed.senderPublicKey);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return { ok: false, cause: 'unreadable' };
|
|
160
|
+
}
|
|
161
|
+
if (!opened.answer || opened.answer.ask !== askId || opened.ask || opened.cmd)
|
|
162
|
+
return { ok: false, cause: 'wrong-labels' };
|
|
163
|
+
const { actionId, exit } = opened.answer;
|
|
164
|
+
if (actionId !== undefined && (offered === undefined || !offered.includes(actionId)))
|
|
165
|
+
return { ok: false, cause: 'unknown-action' };
|
|
166
|
+
if (actionId === undefined && !opened.body && exit !== true)
|
|
167
|
+
return { ok: false, cause: 'empty-answer' };
|
|
168
|
+
return {
|
|
169
|
+
ok: true,
|
|
170
|
+
...(actionId === undefined ? {} : { actionId }),
|
|
171
|
+
...(opened.body ? { value: opened.body } : {}),
|
|
172
|
+
...(exit === true ? { exit } : {}),
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
exports.openAnswer = openAnswer;
|
|
176
|
+
const isSealedFields = (value) => typeof value === 'object' && value !== null
|
|
177
|
+
&& 'body' in value && typeof value.body === 'string'
|
|
178
|
+
&& 'senderPublicKey' in value && typeof value.senderPublicKey === 'string'
|
|
179
|
+
&& 'deviceKeyMap' in value && typeof value.deviceKeyMap === 'object' && value.deviceKeyMap !== null;
|
package/dist/ask.d.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { type AskSealDeps } from './ask-seal.js';
|
|
2
|
+
import { type PendingAsks } from './ask-pending.js';
|
|
3
|
+
/** Poll cadence: every second for the first 30 s, when a tap is most likely
|
|
4
|
+
* and should feel immediate, then every 2.5 s — a 10-minute wait is about
|
|
5
|
+
* 250 requests instead of 600. Exported for tests. */
|
|
6
|
+
export declare const pollIntervalMs: (waitedMs: number) => number;
|
|
7
|
+
export interface AskAction {
|
|
8
|
+
id: string;
|
|
9
|
+
label: string;
|
|
10
|
+
}
|
|
11
|
+
export interface AskOptions {
|
|
12
|
+
apiKey: string;
|
|
13
|
+
baseUrl: string;
|
|
14
|
+
hookId: string;
|
|
15
|
+
title: string;
|
|
16
|
+
body?: string;
|
|
17
|
+
actions?: AskAction[];
|
|
18
|
+
timeoutSeconds: number;
|
|
19
|
+
/**
|
|
20
|
+
* Stable agent-session key, when this ask was raised from a tmux agent
|
|
21
|
+
* pane. Both fields or neither: the phone's deep link needs the pair to
|
|
22
|
+
* open the agent chat — which is where the live terminal and the key row
|
|
23
|
+
* live — and falls back to the plain push screen without it. Agents with
|
|
24
|
+
* no MCP (pi, codex) reach the phone only through this command, so leaving
|
|
25
|
+
* them off is what made their asks land outside the chat.
|
|
26
|
+
*/
|
|
27
|
+
agentDeviceId?: string;
|
|
28
|
+
agentSessionName?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Lets the phone offer "send and exit" on this ask. Only an agent's own
|
|
31
|
+
* `hwip_ask` stand-in sets it (pi's rules pass `--accepts-exit`); an
|
|
32
|
+
* approval gate has no mode to end, so it never offers the button and a
|
|
33
|
+
* stray `exitRemote` on its answer is dropped.
|
|
34
|
+
*/
|
|
35
|
+
acceptsExit?: boolean;
|
|
36
|
+
}
|
|
37
|
+
/** Injected so the poll loop is testable without a clock or a network. */
|
|
38
|
+
export interface AskDeps {
|
|
39
|
+
fetchFn: typeof fetch;
|
|
40
|
+
now: () => number;
|
|
41
|
+
sleep: (ms: number) => Promise<void>;
|
|
42
|
+
/** How a question is sealed. There is no plain question: one that cannot be sealed is refused. */
|
|
43
|
+
seal: AskSealDeps;
|
|
44
|
+
/** 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
|
+
pending?: PendingAsks;
|
|
46
|
+
}
|
|
47
|
+
/** Why an ask ended unanswered: the user cancelled it on the phone, nobody
|
|
48
|
+
* answered in time, or the server could not be reached or refused. */
|
|
49
|
+
export type UnansweredReason = 'cancelled' | 'timeout' | 'error';
|
|
50
|
+
export type AskOutcome = ({
|
|
51
|
+
readonly answered: true;
|
|
52
|
+
readonly actionId: string;
|
|
53
|
+
} | {
|
|
54
|
+
readonly answered: true;
|
|
55
|
+
readonly value: string;
|
|
56
|
+
readonly exitRemote?: true;
|
|
57
|
+
} | {
|
|
58
|
+
readonly answered: false;
|
|
59
|
+
readonly reason: UnansweredReason;
|
|
60
|
+
readonly error?: string;
|
|
61
|
+
}) & {
|
|
62
|
+
/** This is the end of a question an earlier run of the same command put up and did not see through: nothing was sent. */
|
|
63
|
+
readonly resumed?: true;
|
|
64
|
+
/** The question is over but its record could not be removed: the same command rerun in time would take up this answer again. Survives `2>/dev/null`. */
|
|
65
|
+
readonly warn?: string;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* `id:Label` pairs, comma separated. The label may contain colons — only the
|
|
69
|
+
* first one separates, so `go:Deploy: prod` keeps its punctuation. A bare
|
|
70
|
+
* segment becomes its own label, which is what someone typing `--actions ok`
|
|
71
|
+
* means.
|
|
72
|
+
*/
|
|
73
|
+
export declare const parseActions: (spec?: string) => AskAction[];
|
|
74
|
+
/**
|
|
75
|
+
* Ask, then wait. Resolves when the user answers, the question settles
|
|
76
|
+
* without an answer, or the deadline passes — never rejects. An unanswered
|
|
77
|
+
* outcome says why in `reason`, and carries the last error when there was one.
|
|
78
|
+
*
|
|
79
|
+
* A question an earlier run of the same command put up and did not see through is waited on first, and
|
|
80
|
+
* nothing is sent: the user may have answered it already, and a second card would sit next to the first.
|
|
81
|
+
*/
|
|
82
|
+
export declare const requestApproval: (opts: AskOptions, deps: AskDeps) => Promise<AskOutcome>;
|
|
83
|
+
/**
|
|
84
|
+
* What an ask outcome does to sticky REMOTE — mcp-server's
|
|
85
|
+
* `remoteTransitionFor`, for the outcomes this command can produce. A
|
|
86
|
+
* Done-like button or a send-and-exit answer ends it; any other answer enters
|
|
87
|
+
* or refreshes it. An unanswered ask changes nothing: there is no fallback id
|
|
88
|
+
* here to resolve to, and silence is not a user action.
|
|
89
|
+
*/
|
|
90
|
+
export declare const remoteTransition: (outcome: AskOutcome) => "enter" | "exit" | "keep";
|
|
91
|
+
/** The seal's world for real use: the account API, this machine's keys, the listener's trust file. */
|
|
92
|
+
export declare const liveSealDeps: (apiKey: string, baseUrl: string, deadline?: number) => AskSealDeps;
|
|
93
|
+
/** Wall-clock deps for real use. `deadline` is when the question's window ends: with one, a sealed question carries its context. */
|
|
94
|
+
export declare const liveDeps: (account: {
|
|
95
|
+
apiKey: string;
|
|
96
|
+
baseUrl: string;
|
|
97
|
+
deadline?: number;
|
|
98
|
+
}) => AskDeps;
|
|
99
|
+
/**
|
|
100
|
+
* `hwip ask --title … [--body …] [--actions id:Label,…] [--timeout 60] [--accepts-exit]`
|
|
101
|
+
*
|
|
102
|
+
* Prints one JSON object and nothing else, so a hook can pipe it straight into
|
|
103
|
+
* `jq`. Exit code 0 means answered, 1 means not — a shell caller that only
|
|
104
|
+
* wants approve/deny can read `$?` and skip the JSON entirely.
|
|
105
|
+
*/
|
|
106
|
+
export declare const handleAsk: (args: Record<string, string | boolean>) => Promise<number>;
|
|
107
|
+
//# sourceMappingURL=ask.d.ts.map
|