@hwip/mcp-server 0.0.0-stage → 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.
Files changed (78) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +10 -0
  3. package/README.md +413 -2
  4. package/dist/agent-target.d.ts +45 -0
  5. package/dist/agent-target.js +81 -0
  6. package/dist/api-client.d.ts +162 -0
  7. package/dist/api-client.js +517 -0
  8. package/dist/ask-context-attach.d.ts +67 -0
  9. package/dist/ask-context-attach.js +147 -0
  10. package/dist/ask-seal.d.ts +118 -0
  11. package/dist/ask-seal.js +194 -0
  12. package/dist/command-seal.d.ts +49 -0
  13. package/dist/command-seal.js +90 -0
  14. package/dist/command-seq.d.ts +30 -0
  15. package/dist/command-seq.js +147 -0
  16. package/dist/config.d.ts +93 -0
  17. package/dist/config.js +561 -0
  18. package/dist/crypto.d.ts +234 -0
  19. package/dist/crypto.js +606 -0
  20. package/dist/error-format.d.ts +7 -0
  21. package/dist/error-format.js +77 -0
  22. package/dist/index.d.ts +3 -0
  23. package/dist/index.js +30 -0
  24. package/dist/key-changes.d.ts +50 -0
  25. package/dist/key-changes.js +163 -0
  26. package/dist/mime.d.ts +16 -0
  27. package/dist/mime.js +58 -0
  28. package/dist/peer-key.d.ts +36 -0
  29. package/dist/peer-key.js +35 -0
  30. package/dist/poll.d.ts +24 -0
  31. package/dist/poll.js +156 -0
  32. package/dist/push-audience.d.ts +35 -0
  33. package/dist/push-audience.js +64 -0
  34. package/dist/redirect.d.ts +8 -0
  35. package/dist/redirect.js +17 -0
  36. package/dist/remote-state.d.ts +38 -0
  37. package/dist/remote-state.js +81 -0
  38. package/dist/resources/channels.d.ts +4 -0
  39. package/dist/resources/channels.js +24 -0
  40. package/dist/resources/devices.d.ts +4 -0
  41. package/dist/resources/devices.js +24 -0
  42. package/dist/sanitize.d.ts +41 -0
  43. package/dist/sanitize.js +106 -0
  44. package/dist/self-register.d.ts +34 -0
  45. package/dist/self-register.js +51 -0
  46. package/dist/server.d.ts +4 -0
  47. package/dist/server.js +63 -0
  48. package/dist/tools/agent-local.d.ts +17 -0
  49. package/dist/tools/agent-local.js +139 -0
  50. package/dist/tools/agent-send.d.ts +6 -0
  51. package/dist/tools/agent-send.js +89 -0
  52. package/dist/tools/ask.d.ts +9 -0
  53. package/dist/tools/ask.js +275 -0
  54. package/dist/tools/broadcast.d.ts +5 -0
  55. package/dist/tools/broadcast.js +42 -0
  56. package/dist/tools/clipboard.d.ts +5 -0
  57. package/dist/tools/clipboard.js +47 -0
  58. package/dist/tools/dismiss.d.ts +5 -0
  59. package/dist/tools/dismiss.js +49 -0
  60. package/dist/tools/file.d.ts +5 -0
  61. package/dist/tools/file.js +169 -0
  62. package/dist/tools/list.d.ts +4 -0
  63. package/dist/tools/list.js +44 -0
  64. package/dist/tools/lookup.d.ts +10 -0
  65. package/dist/tools/lookup.js +45 -0
  66. package/dist/tools/notify.d.ts +5 -0
  67. package/dist/tools/notify.js +118 -0
  68. package/dist/tools/rename.d.ts +5 -0
  69. package/dist/tools/rename.js +45 -0
  70. package/dist/trust-pins.d.ts +70 -0
  71. package/dist/trust-pins.js +269 -0
  72. package/dist/types.d.ts +157 -0
  73. package/dist/types.js +2 -0
  74. package/dist/url-check.d.ts +11 -0
  75. package/dist/url-check.js +57 -0
  76. package/dist/ws-wait.d.ts +72 -0
  77. package/dist/ws-wait.js +200 -0
  78. package/package.json +70 -4
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatToolError = exports.hookNotConfiguredError = exports.errorResult = exports.textResult = void 0;
4
+ const api_client_js_1 = require("./api-client.js");
5
+ const poll_js_1 = require("./poll.js");
6
+ const textResult = (obj) => ({
7
+ content: [{ type: 'text', text: JSON.stringify(obj) }],
8
+ });
9
+ exports.textResult = textResult;
10
+ const errorResult = (obj) => ({
11
+ content: [{ type: 'text', text: JSON.stringify(obj) }],
12
+ isError: true,
13
+ });
14
+ exports.errorResult = errorResult;
15
+ const hookNotConfiguredError = () => (0, exports.errorResult)({
16
+ error: 'HOOK_NOT_CONFIGURED',
17
+ message: 'No hook id: neither HWIP_HOOK_ID nor hookId in ~/.hwip/config.json is set, so a question cannot reach the phone',
18
+ suggestion: 'Ask in this chat instead, and use hwip_notify if the user should be pinged about it. '
19
+ + 'To enable hwip_ask, the user runs `hwip login` (it writes hookId to ~/.hwip/config.json) or sets HWIP_HOOK_ID.',
20
+ });
21
+ exports.hookNotConfiguredError = hookNotConfiguredError;
22
+ /** What the agent should do after each way an ask can end unanswered. */
23
+ const ASK_SUGGESTIONS = {
24
+ CANCELLED: 'The user dismissed this question on purpose. Do not ask it again — carry on without it, or ask something different.',
25
+ POLL_FAILED: 'Do not re-ask blindly — the question may still be on the phone and a repeat pushes a duplicate. Tell the user in the terminal what you were waiting on.',
26
+ ABORTED: 'The tool call was cancelled before the user answered.',
27
+ };
28
+ const formatToolError = (err) => {
29
+ const toolError = {
30
+ error: 'UNKNOWN',
31
+ message: err instanceof Error ? err.message : String(err),
32
+ };
33
+ if (err instanceof poll_js_1.AskError) {
34
+ toolError.error = err.code;
35
+ if (err.eventId)
36
+ toolError.eventId = err.eventId;
37
+ toolError.suggestion = ASK_SUGGESTIONS[err.code];
38
+ }
39
+ else if (err instanceof api_client_js_1.ApiError) {
40
+ toolError.error = err.code;
41
+ toolError.message = err.message;
42
+ if (err.code === 'QUOTA_EXCEEDED') {
43
+ toolError.suggestion = 'Monthly quota exceeded. Upgrade plan for higher limits';
44
+ }
45
+ else if (err.code === 'UNAUTHORIZED') {
46
+ toolError.suggestion = 'Check HWIP_API_KEY environment variable or ~/.hwip/config.json';
47
+ }
48
+ else if (err.code === 'HOOK_DISABLED') {
49
+ toolError.suggestion = 'Enable the Hook in Settings → Hooks';
50
+ }
51
+ else if (err.code === 'NETWORK_ERROR') {
52
+ toolError.suggestion = err.causeCode === 'ERR_INVALID_URL'
53
+ ? 'Do not retry. The configured API base URL is not an address; the user has to correct HWIP_BASE_URL (or baseUrl in ~/.hwip/config.json)'
54
+ : 'No connection to the hwip API. Retry once later; if it keeps failing, carry on in the chat';
55
+ }
56
+ else if (err.code === 'INVALID_UPLOAD_URL') {
57
+ toolError.suggestion = 'Do not retry, and do not correct HWIP_BASE_URL for it — the upload address came from the hwip API\'s own reply, not from the configuration. Tell the user in the terminal which file was not uploaded';
58
+ }
59
+ else if (err.code === 'REDIRECT_REFUSED') {
60
+ toolError.suggestion = 'Do not retry. The configured API base URL redirects somewhere else; the user has to correct HWIP_BASE_URL (or baseUrl in ~/.hwip/config.json)';
61
+ }
62
+ else if (err.code === 'API_TIMEOUT') {
63
+ toolError.suggestion = 'The hwip API was slow, not the user. If this was a send, check hwip_list before sending again — it may have gone out';
64
+ }
65
+ else if (err.code === 'BAD_RESPONSE') {
66
+ toolError.suggestion = 'The answer was not the hwip API\'s own reply. If this was a send, check hwip_list before sending again — it may have gone out';
67
+ }
68
+ else if (err.code === 'SEND_UNCONFIRMED') {
69
+ toolError.suggestion = 'Check hwip_list before asking again — the question may have gone out';
70
+ }
71
+ else if (err.code === 'FORBIDDEN') {
72
+ toolError.suggestion = 'Check the API key scopes — a key made with the MCP preset covers every tool';
73
+ }
74
+ }
75
+ return (0, exports.errorResult)(toolError);
76
+ };
77
+ exports.formatToolError = formatToolError;
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
5
+ const config_js_1 = require("./config.js");
6
+ const crypto_js_1 = require("./crypto.js");
7
+ const server_js_1 = require("./server.js");
8
+ const ws_wait_js_1 = require("./ws-wait.js");
9
+ const main = async () => {
10
+ const config = (0, config_js_1.loadConfig)();
11
+ // Read only to report: the variable used to outrank the config file, and
12
+ // removing it without a word would leave the same silence behind.
13
+ const staleWsEnv = (0, config_js_1.legacyWsEnvNotice)();
14
+ if (staleWsEnv)
15
+ console.error(staleWsEnv);
16
+ const noFastPath = (0, ws_wait_js_1.wsFastPathNotice)(config.wsUrl);
17
+ if (noFastPath)
18
+ console.error(noFastPath);
19
+ // Load or create this host's keypair. Runs once per process and caches.
20
+ // Not awaited: a send waits for it instead (`cryptoReady`).
21
+ (0, crypto_js_1.initCrypto)(config.apiKey).then(() => console.error((0, crypto_js_1.cryptoStatusLine)()), (err) => console.error('[Crypto] E2E encryption unavailable:', err));
22
+ const server = (0, server_js_1.createServer)(config);
23
+ const transport = new stdio_js_1.StdioServerTransport();
24
+ await server.connect(transport);
25
+ console.error('Hwip MCP Server running on stdio');
26
+ };
27
+ main().catch((err) => {
28
+ console.error('Fatal error:', err);
29
+ process.exit(1);
30
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Devices whose key changed since this computer pinned them, kept until a person has been shown it (twin of the cli's
3
+ * `src/key-changes.ts`, same contract).
4
+ *
5
+ * Whoever replaces a pin — the cli's notify, ask, agent send and listener, and this server — writes the change here,
6
+ * under the trust file's lock (`withTrustLock`). This server's stderr reaches nobody, and once the pin holds the new
7
+ * key no one sees the change again. So it waits beside the pins until a surface a person reads takes it: here that
8
+ * is the result of `hwip_notify`, `hwip_file`, `hwip_ask` and `hwip_agent_send`, which ends with a `Note:` line.
9
+ *
10
+ * THE CONTRACT (cli and mcp-server write and read it, the plugin reads it — changing it is a cross-repo change):
11
+ *
12
+ * path key-changes.json beside trusted-devices.json (~/.hwip/key-changes.json), 0600
13
+ * body {"v":1,"changes":[{"deviceId":"<device id>","at":"<ISO 8601 UTC>","name":"<nickname, optional>"}]}
14
+ * one entry per device, updated in place; at most 64, the oldest dropped
15
+ * take under the same lock as the writers (trusted-devices.lock), rename to key-changes.json.<pid>.taken, read,
16
+ * delete: a writer cannot put back what was taken, and of two readers only the one whose rename wins shows
17
+ * the changes. A record that does not parse, or another `v`, reads as none.
18
+ */
19
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
20
+ export interface KeyChange {
21
+ deviceId: string;
22
+ /** When the pin was replaced, ISO 8601 UTC. */
23
+ at: string;
24
+ /** The device's nickname as the account lists it. */
25
+ name?: string;
26
+ }
27
+ export declare const MAX_KEY_CHANGES = 64;
28
+ export declare const keyChangesPath: (trustPath: string) => string;
29
+ /**
30
+ * Add `changes` to the record. Call it inside the `withTrustLock` section that replaced the pins: that lock is what
31
+ * keeps two writers from dropping each other's entry, and a taker from seeing an entry twice. Throws nothing: false
32
+ * means the record could not be written, and the caller holds the changes instead (`holdKeyChanges`).
33
+ */
34
+ export declare const recordKeyChanges: (trustPath: string, changes: KeyChange[]) => boolean;
35
+ export declare const holdKeyChanges: (changes: KeyChange[]) => void;
36
+ /** The changes nobody has been shown yet, and the record gone. A lock that cannot be had leaves the record for the next result. */
37
+ export declare const takeKeyChanges: (trustPath: string, opts?: {
38
+ waitMs?: number;
39
+ }) => KeyChange[];
40
+ export declare const noteOnce: (text: string) => void;
41
+ /** One line for a person; the caller adds its prefix. */
42
+ export declare const describeKeyChange: (change: KeyChange) => string;
43
+ /**
44
+ * `result` ending with one `Note:` text block per change not shown yet and per `noteOnce` line, which are then cleared.
45
+ * Only text: a tool's JSON keeps its keys.
46
+ */
47
+ export declare const withKeyChangeNote: (result: CallToolResult, trustPath?: string) => CallToolResult;
48
+ /** A tool handler whose result carries the key changes not shown yet (`withKeyChangeNote`). */
49
+ export declare const notingKeyChanges: <A extends unknown[]>(handler: (...args: A) => Promise<CallToolResult>) => (...args: A) => Promise<CallToolResult>;
50
+ //# sourceMappingURL=key-changes.d.ts.map
@@ -0,0 +1,163 @@
1
+ "use strict";
2
+ /**
3
+ * Devices whose key changed since this computer pinned them, kept until a person has been shown it (twin of the cli's
4
+ * `src/key-changes.ts`, same contract).
5
+ *
6
+ * Whoever replaces a pin — the cli's notify, ask, agent send and listener, and this server — writes the change here,
7
+ * under the trust file's lock (`withTrustLock`). This server's stderr reaches nobody, and once the pin holds the new
8
+ * key no one sees the change again. So it waits beside the pins until a surface a person reads takes it: here that
9
+ * is the result of `hwip_notify`, `hwip_file`, `hwip_ask` and `hwip_agent_send`, which ends with a `Note:` line.
10
+ *
11
+ * THE CONTRACT (cli and mcp-server write and read it, the plugin reads it — changing it is a cross-repo change):
12
+ *
13
+ * path key-changes.json beside trusted-devices.json (~/.hwip/key-changes.json), 0600
14
+ * body {"v":1,"changes":[{"deviceId":"<device id>","at":"<ISO 8601 UTC>","name":"<nickname, optional>"}]}
15
+ * one entry per device, updated in place; at most 64, the oldest dropped
16
+ * take under the same lock as the writers (trusted-devices.lock), rename to key-changes.json.<pid>.taken, read,
17
+ * delete: a writer cannot put back what was taken, and of two readers only the one whose rename wins shows
18
+ * the changes. A record that does not parse, or another `v`, reads as none.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.notingKeyChanges = exports.withKeyChangeNote = exports.describeKeyChange = exports.noteOnce = exports.takeKeyChanges = exports.holdKeyChanges = exports.recordKeyChanges = exports.keyChangesPath = exports.MAX_KEY_CHANGES = void 0;
22
+ const node_fs_1 = require("node:fs");
23
+ const node_crypto_1 = require("node:crypto");
24
+ const node_path_1 = require("node:path");
25
+ const trust_pins_js_1 = require("./trust-pins.js");
26
+ exports.MAX_KEY_CHANGES = 64;
27
+ const NAME_MAX = 80;
28
+ const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
29
+ const keyChangesPath = (trustPath) => (0, node_path_1.join)((0, node_path_1.dirname)(trustPath), 'key-changes.json');
30
+ exports.keyChangesPath = keyChangesPath;
31
+ /** The relay writes nicknames: no control, direction or zero-width characters, one space per run, 80 characters at most. */
32
+ const cleanName = (name) => {
33
+ if (typeof name !== 'string')
34
+ return undefined;
35
+ const plain = name.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]|\p{Cf}/gu, '').replace(/\s+/g, ' ').trim();
36
+ const chars = Array.from(plain);
37
+ if (chars.length === 0)
38
+ return undefined;
39
+ return chars.length > NAME_MAX ? chars.slice(0, NAME_MAX).join('') : plain;
40
+ };
41
+ const entryOf = (raw) => {
42
+ if (typeof raw !== 'object' || raw === null)
43
+ return null;
44
+ const { deviceId, at, name } = raw;
45
+ if (typeof deviceId !== 'string' || !trust_pins_js_1.DEVICE_ID.test(deviceId) || typeof at !== 'string' || !ISO_UTC.test(at))
46
+ return null;
47
+ const clean = cleanName(name);
48
+ return clean === undefined ? { deviceId, at } : { deviceId, at, name: clean };
49
+ };
50
+ const readChanges = (path) => {
51
+ let raw;
52
+ try {
53
+ raw = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf-8'));
54
+ }
55
+ catch {
56
+ return [];
57
+ }
58
+ const { v, changes } = (raw ?? {});
59
+ if (v !== 1 || !Array.isArray(changes))
60
+ return [];
61
+ return changes.map(entryOf).filter((c) => c !== null).slice(-exports.MAX_KEY_CHANGES);
62
+ };
63
+ /** One entry per device, the later one in place of the earlier. */
64
+ const merge = (list, changes) => {
65
+ for (const change of changes) {
66
+ const entry = entryOf(change);
67
+ if (!entry)
68
+ continue;
69
+ const at = list.findIndex((c) => c.deviceId === entry.deviceId);
70
+ if (at >= 0)
71
+ list[at] = entry;
72
+ else
73
+ list.push(entry);
74
+ }
75
+ return list.slice(-exports.MAX_KEY_CHANGES);
76
+ };
77
+ /**
78
+ * Add `changes` to the record. Call it inside the `withTrustLock` section that replaced the pins: that lock is what
79
+ * keeps two writers from dropping each other's entry, and a taker from seeing an entry twice. Throws nothing: false
80
+ * means the record could not be written, and the caller holds the changes instead (`holdKeyChanges`).
81
+ */
82
+ const recordKeyChanges = (trustPath, changes) => {
83
+ if (changes.length === 0)
84
+ return true;
85
+ const path = (0, exports.keyChangesPath)(trustPath);
86
+ const list = merge(readChanges(path), changes);
87
+ const temp = `${path}.${process.pid}.${(0, node_crypto_1.randomBytes)(6).toString('hex')}.tmp`;
88
+ try {
89
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true, mode: 0o700 });
90
+ (0, node_fs_1.writeFileSync)(temp, `${JSON.stringify({ v: 1, changes: list })}\n`, { mode: 0o600, flag: 'wx' });
91
+ (0, node_fs_1.renameSync)(temp, path);
92
+ return true;
93
+ }
94
+ catch {
95
+ try {
96
+ (0, node_fs_1.rmSync)(temp, { force: true });
97
+ }
98
+ catch { /* the folder is not there either */ }
99
+ return false;
100
+ }
101
+ };
102
+ exports.recordKeyChanges = recordKeyChanges;
103
+ /** Changes this process saw but could not record (the pin or the record was not written): shown by its next result. */
104
+ let held = [];
105
+ const holdKeyChanges = (changes) => {
106
+ held = merge(held, changes);
107
+ };
108
+ exports.holdKeyChanges = holdKeyChanges;
109
+ const takeRecord = (path) => {
110
+ const taken = `${path}.${process.pid}.taken`;
111
+ try {
112
+ (0, node_fs_1.renameSync)(path, taken);
113
+ }
114
+ catch {
115
+ return [];
116
+ }
117
+ try {
118
+ return readChanges(taken);
119
+ }
120
+ finally {
121
+ (0, node_fs_1.rmSync)(taken, { force: true });
122
+ }
123
+ };
124
+ /** The changes nobody has been shown yet, and the record gone. A lock that cannot be had leaves the record for the next result. */
125
+ const takeKeyChanges = (trustPath, opts = {}) => {
126
+ const mine = held;
127
+ held = [];
128
+ const path = (0, exports.keyChangesPath)(trustPath);
129
+ // Most results find nothing: no lock for them.
130
+ if (!(0, node_fs_1.existsSync)(path))
131
+ return mine;
132
+ const done = (0, trust_pins_js_1.withTrustLock)(trustPath, () => takeRecord(path), opts);
133
+ return done.locked ? merge(mine, done.value) : mine;
134
+ };
135
+ exports.takeKeyChanges = takeKeyChanges;
136
+ /** Other lines for the next result, each said once per process: a failure that repeats on every send is said the first time. */
137
+ const said = new Set();
138
+ let notes = [];
139
+ const noteOnce = (text) => {
140
+ if (said.has(text))
141
+ return;
142
+ said.add(text);
143
+ notes.push(text);
144
+ };
145
+ exports.noteOnce = noteOnce;
146
+ /** One line for a person; the caller adds its prefix. */
147
+ const describeKeyChange = (change) => `${change.name ?? change.deviceId} changed its encryption key — the new key is trusted. If you did not expect this, check it with: hwip trust`;
148
+ exports.describeKeyChange = describeKeyChange;
149
+ /**
150
+ * `result` ending with one `Note:` text block per change not shown yet and per `noteOnce` line, which are then cleared.
151
+ * Only text: a tool's JSON keeps its keys.
152
+ */
153
+ const withKeyChangeNote = (result, trustPath = trust_pins_js_1.TRUST_PATH) => {
154
+ const lines = [...(0, exports.takeKeyChanges)(trustPath).map(exports.describeKeyChange), ...notes];
155
+ notes = [];
156
+ if (lines.length === 0)
157
+ return result;
158
+ return { ...result, content: [...result.content, ...lines.map((line) => ({ type: 'text', text: `Note: ${line}` }))] };
159
+ };
160
+ exports.withKeyChangeNote = withKeyChangeNote;
161
+ /** A tool handler whose result carries the key changes not shown yet (`withKeyChangeNote`). */
162
+ const notingKeyChanges = (handler) => async (...args) => (0, exports.withKeyChangeNote)(await handler(...args));
163
+ exports.notingKeyChanges = notingKeyChanges;
package/dist/mime.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Shared MIME-type inference for file/notify/ask payloads.
3
+ *
4
+ * Kept in one place so different tools don't produce inconsistent types
5
+ * for the same extension (e.g. `.csv` ending up as text/plain in one
6
+ * code path and text/csv in another).
7
+ */
8
+ export declare const EXT_TO_MIME: Record<string, string>;
9
+ export declare const inferMimeType: (fileName: string) => string;
10
+ /**
11
+ * The Content-Type header an upload PUT has to carry, whatever its body: the address is signed for it,
12
+ * and S3 answers 403 to any other. It is the type as sent to `POST /files/upload-request`, with
13
+ * `; charset=utf-8` added to `text/*` that has no charset (the hwip server's `uploadContentType`).
14
+ */
15
+ export declare const uploadContentType: (contentType: string) => string;
16
+ //# sourceMappingURL=mime.d.ts.map
package/dist/mime.js ADDED
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ /**
3
+ * Shared MIME-type inference for file/notify/ask payloads.
4
+ *
5
+ * Kept in one place so different tools don't produce inconsistent types
6
+ * for the same extension (e.g. `.csv` ending up as text/plain in one
7
+ * code path and text/csv in another).
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.uploadContentType = exports.inferMimeType = exports.EXT_TO_MIME = void 0;
11
+ exports.EXT_TO_MIME = {
12
+ txt: 'text/plain',
13
+ log: 'text/plain',
14
+ md: 'text/markdown',
15
+ json: 'application/json',
16
+ csv: 'text/csv',
17
+ yaml: 'text/yaml',
18
+ yml: 'text/yaml',
19
+ ts: 'text/typescript',
20
+ js: 'text/javascript',
21
+ py: 'text/x-python',
22
+ sh: 'text/x-shellscript',
23
+ // Keep this image set in sync with IMAGE_EXTENSIONS in the Hwip client
24
+ // (`libs/shared/src/utils/file.ts`) — an extension the client calls an image
25
+ // but this map doesn't will be labelled text/plain and decoded as UTF-8 on
26
+ // open, which corrupts it. Separate repos, so nothing enforces the match.
27
+ png: 'image/png',
28
+ jpg: 'image/jpeg',
29
+ jpeg: 'image/jpeg',
30
+ gif: 'image/gif',
31
+ webp: 'image/webp',
32
+ heic: 'image/heic',
33
+ heif: 'image/heif',
34
+ bmp: 'image/bmp',
35
+ avif: 'image/avif',
36
+ ico: 'image/x-icon',
37
+ tiff: 'image/tiff',
38
+ tif: 'image/tiff',
39
+ // No html, xml or svg entry on purpose: these are documents a browser runs, and the
40
+ // clients open decrypted attachments from a same-origin blob URL. The server also refuses
41
+ // them as an upload type (they would run on the bucket's origin). They fall through to
42
+ // text/plain, which the text viewer handles fine.
43
+ pdf: 'application/pdf',
44
+ };
45
+ const inferMimeType = (fileName) => {
46
+ const ext = fileName.split('.').pop()?.toLowerCase();
47
+ return exports.EXT_TO_MIME[ext ?? ''] ?? 'text/plain';
48
+ };
49
+ exports.inferMimeType = inferMimeType;
50
+ /**
51
+ * The Content-Type header an upload PUT has to carry, whatever its body: the address is signed for it,
52
+ * and S3 answers 403 to any other. It is the type as sent to `POST /files/upload-request`, with
53
+ * `; charset=utf-8` added to `text/*` that has no charset (the hwip server's `uploadContentType`).
54
+ */
55
+ const uploadContentType = (contentType) => contentType.startsWith('text/') && !contentType.includes('charset')
56
+ ? `${contentType}; charset=utf-8`
57
+ : contentType;
58
+ exports.uploadContentType = uploadContentType;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Which key to believe for a device that signs something with its own key
3
+ * (twin of the cli's `src/peer-key.ts`, same rules).
4
+ *
5
+ * An envelope that opens only shows its sender held *a* key (push v1 carries
6
+ * the sender's public key unsigned), so the reader must say whose key it
7
+ * expects. This answers that for one device id: the key the account's device
8
+ * list registers for it. Trust never refuses (F16): the key is pinned the first
9
+ * time it is seen, and a changed key replaces its pin with one warning
10
+ * (`observeDevices`, `~/.hwip/trusted-devices.json`).
11
+ */
12
+ export type PeerKeyRefusal =
13
+ /** Not a device id, or not in the account's device list. */
14
+ 'unlisted'
15
+ /** The device list could not be fetched. */
16
+ | 'devices_unavailable';
17
+ export type PeerKey = {
18
+ key: string;
19
+ } | {
20
+ refused: PeerKeyRefusal;
21
+ };
22
+ export interface PeerKeyDeps {
23
+ /** The key the account's device list registers for `deviceId`: null when none is listed, a rejection when the list cannot be had. */
24
+ registeredKey: (deviceId: string) => Promise<string | null>;
25
+ /** The listener's state file. Defaults to `~/.hwip/trusted-devices.json`. */
26
+ trustPath?: string;
27
+ /** This machine. Asking about oneself needs no list. */
28
+ self?: {
29
+ deviceId: string;
30
+ publicKey: string;
31
+ };
32
+ /** This machine's id when `self` is left out so its key cannot vouch: it is still never pinned. */
33
+ selfId?: string;
34
+ }
35
+ export declare const peerKeyOf: (deviceId: string, deps: PeerKeyDeps) => Promise<PeerKey>;
36
+ //# sourceMappingURL=peer-key.d.ts.map
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ /**
3
+ * Which key to believe for a device that signs something with its own key
4
+ * (twin of the cli's `src/peer-key.ts`, same rules).
5
+ *
6
+ * An envelope that opens only shows its sender held *a* key (push v1 carries
7
+ * the sender's public key unsigned), so the reader must say whose key it
8
+ * expects. This answers that for one device id: the key the account's device
9
+ * list registers for it. Trust never refuses (F16): the key is pinned the first
10
+ * time it is seen, and a changed key replaces its pin with one warning
11
+ * (`observeDevices`, `~/.hwip/trusted-devices.json`).
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.peerKeyOf = void 0;
15
+ const crypto_js_1 = require("./crypto.js");
16
+ const trust_pins_js_1 = require("./trust-pins.js");
17
+ const peerKeyOf = async (deviceId, deps) => {
18
+ if (!trust_pins_js_1.DEVICE_ID.test(deviceId))
19
+ return { refused: 'unlisted' };
20
+ if (deps.self?.deviceId === deviceId)
21
+ return { key: deps.self.publicKey };
22
+ let registered;
23
+ try {
24
+ registered = await deps.registeredKey(deviceId);
25
+ }
26
+ catch {
27
+ return { refused: 'devices_unavailable' };
28
+ }
29
+ // A listed key that is no device key in the one spelling is as good as none.
30
+ if (!registered || !(0, crypto_js_1.samePublicKey)(registered, registered))
31
+ return { refused: 'unlisted' };
32
+ (0, trust_pins_js_1.observeDevices)(deps.trustPath ?? trust_pins_js_1.TRUST_PATH, [{ deviceId, publicKey: registered }], { selfId: deps.self?.deviceId ?? deps.selfId });
33
+ return { key: registered };
34
+ };
35
+ exports.peerKeyOf = peerKeyOf;
package/dist/poll.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ import type { ServerNotification } from '@modelcontextprotocol/sdk/types.js';
2
+ import { type HwipApiClient } from './api-client.js';
3
+ import type { HookEventResponse } from './types.js';
4
+ import type { HookResponseWaiter } from './ws-wait.js';
5
+ export interface PollContext {
6
+ _meta?: {
7
+ progressToken?: string | number;
8
+ };
9
+ sendNotification: (notification: ServerNotification) => Promise<void>;
10
+ /** Fires when the agent cancels the tool call — the wait ends with it. */
11
+ signal?: AbortSignal;
12
+ }
13
+ /**
14
+ * How a wait ended without an answer, other than the deadline. Each code tells
15
+ * the agent something different to do next, which is the point of having them.
16
+ */
17
+ export type AskFailure = 'CANCELLED' | 'POLL_FAILED' | 'ABORTED';
18
+ export declare class AskError extends Error {
19
+ readonly code: AskFailure;
20
+ readonly eventId: string;
21
+ constructor(code: AskFailure, message: string, eventId: string);
22
+ }
23
+ export declare const pollForResponse: (client: HwipApiClient, hookId: string, eventId: string, timeoutSeconds: number, ctx: PollContext, waiter?: HookResponseWaiter) => Promise<HookEventResponse | null>;
24
+ //# sourceMappingURL=poll.d.ts.map
package/dist/poll.js ADDED
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pollForResponse = exports.AskError = void 0;
4
+ const api_client_js_1 = require("./api-client.js");
5
+ class AskError extends Error {
6
+ code;
7
+ eventId;
8
+ constructor(code, message, eventId) {
9
+ super(message);
10
+ this.code = code;
11
+ this.eventId = eventId;
12
+ this.name = 'AskError';
13
+ }
14
+ }
15
+ exports.AskError = AskError;
16
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
17
+ /** Never settles — the stand-in for a race arm that is absent. */
18
+ const never = new Promise(() => undefined);
19
+ /** Rejects once `signal` aborts; never settles without one. `release` drops the listener. */
20
+ const abortOf = (signal, eventId) => {
21
+ if (!signal)
22
+ return { aborted: never, release: () => undefined };
23
+ let fail = () => undefined;
24
+ const aborted = new Promise((_, reject) => {
25
+ fail = () => reject(new AskError('ABORTED', 'The tool call was cancelled', eventId));
26
+ });
27
+ aborted.catch(() => undefined);
28
+ if (signal.aborted)
29
+ fail();
30
+ else
31
+ signal.addEventListener('abort', fail, { once: true });
32
+ return { aborted, release: () => signal.removeEventListener('abort', fail) };
33
+ };
34
+ /** Backoff after `failures` consecutive failed reads: 1s, 2s, 4s … capped at 15s. */
35
+ const RETRY_BASE_MS = 1_000;
36
+ const RETRY_CAP_MS = 15_000;
37
+ const retryDelay = (failures) => Math.min(RETRY_BASE_MS * 2 ** (failures - 1), RETRY_CAP_MS);
38
+ const PROGRESS_INTERVAL_SECONDS = 5;
39
+ // While the WS fast path is live, polling is only the consistency
40
+ // backstop — 10s is plenty. The WS wake races the sleep, so a live
41
+ // answer still lands in sub-second time (SPEC-AGENT-AWARENESS §S3).
42
+ const WS_BACKSTOP_INTERVAL_MS = 10_000;
43
+ // After a WS wake, REST may briefly still read 'pending' (DDB eventual
44
+ // consistency) — retry tightly but boundedly.
45
+ const POST_WAKE_RETRY_MS = 500;
46
+ const pollForResponse = async (client, hookId, eventId, timeoutSeconds, ctx, waiter) => {
47
+ const progressToken = ctx._meta?.progressToken;
48
+ const startTime = Date.now();
49
+ const timeoutMs = timeoutSeconds * 1000;
50
+ let attempt = 0;
51
+ let lastProgressAt = 0;
52
+ let sub;
53
+ let wsSettled = false;
54
+ if (waiter?.canWait()) {
55
+ sub = waiter.subscribe(eventId);
56
+ void sub.settled.then(() => { wsSettled = true; });
57
+ }
58
+ const { aborted, release } = abortOf(ctx.signal, eventId);
59
+ // Raced so a cancelled call returns now, not after an in-flight read.
60
+ const untilAborted = (p) => Promise.race([p, aborted]);
61
+ const wake = sub?.settled ?? never;
62
+ // Throttled progress notification (every 5s) — sent through outages too, so
63
+ // a client that keeps the call alive on progress does not give up mid-retry.
64
+ const reportProgress = async () => {
65
+ if (progressToken === undefined)
66
+ return;
67
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
68
+ if (elapsed - lastProgressAt < PROGRESS_INTERVAL_SECONDS)
69
+ return;
70
+ lastProgressAt = elapsed;
71
+ await ctx.sendNotification({
72
+ method: 'notifications/progress',
73
+ params: {
74
+ progressToken,
75
+ progress: elapsed,
76
+ total: timeoutSeconds,
77
+ message: `Waiting for user response... (${elapsed}s / ${timeoutSeconds}s)`,
78
+ },
79
+ });
80
+ };
81
+ let failures = 0;
82
+ let seen = false;
83
+ let lastReadAt;
84
+ let lastError;
85
+ try {
86
+ while (Date.now() - startTime < timeoutMs) {
87
+ let event;
88
+ try {
89
+ event = await untilAborted(client.getHookEvent(hookId, eventId));
90
+ }
91
+ catch (err) {
92
+ // A 404 before the first good read is the server's eventually
93
+ // consistent read missing the event the trigger just wrote.
94
+ const unseen404 = !seen && err instanceof api_client_js_1.ApiError && err.status === 404;
95
+ if (err instanceof AskError || !((0, api_client_js_1.isTransient)(err) || unseen404))
96
+ throw err;
97
+ // One failed read is not the end of the question: it is still on the
98
+ // phone, and giving up here would leave the answer with nobody to
99
+ // receive it while the agent asks again. Back off and read again.
100
+ failures++;
101
+ lastError = err;
102
+ await reportProgress();
103
+ const remaining = timeoutMs - (Date.now() - startTime);
104
+ await untilAborted(Promise.race([sleep(Math.max(0, Math.min(retryDelay(failures), remaining))), wake]));
105
+ continue;
106
+ }
107
+ failures = 0;
108
+ seen = true;
109
+ lastReadAt = Date.now();
110
+ if (event.data.status === 'responded')
111
+ return event;
112
+ if (event.data.status === 'cancelled') {
113
+ throw new AskError('CANCELLED', 'The user cancelled this question on their phone', eventId);
114
+ }
115
+ if (event.data.status === 'timed_out')
116
+ return null;
117
+ // Only 'pending' should keep us polling. Anything else is server
118
+ // contract drift — fail fast instead of spinning until the timeout.
119
+ if (event.data.status !== 'pending') {
120
+ throw new Error(`Unexpected hook event status: ${(0, api_client_js_1.serverMessage)(String(event.data.status), 'unreadable')}`);
121
+ }
122
+ await reportProgress();
123
+ if (wsSettled) {
124
+ // Server said settled but this read still saw 'pending' —
125
+ // eventual consistency. Tight bounded retry until reads agree.
126
+ await untilAborted(sleep(POST_WAKE_RETRY_MS));
127
+ attempt++;
128
+ continue;
129
+ }
130
+ // Adaptive interval: poll every 1s while the user is likely still at
131
+ // their device (first ~60 attempts ≈ 1 min), then back off to 3s for
132
+ // long waits. The tight 1s window keeps post-tap detection snappy.
133
+ // With a live WS subscription the cadence relaxes to a backstop and
134
+ // the wake race handles snappiness instead.
135
+ const interval = sub?.isConnected()
136
+ ? WS_BACKSTOP_INTERVAL_MS
137
+ : (attempt < 60 ? 1000 : 3000);
138
+ await untilAborted(Promise.race([sleep(interval), wake]));
139
+ attempt++;
140
+ }
141
+ // The deadline passed with the server unreadable for a while (not one
142
+ // stray failure after minutes of clean reads): that is not a timeout the
143
+ // user caused, and the answer may have landed unseen.
144
+ const blind = lastReadAt === undefined || Date.now() - lastReadAt > RETRY_CAP_MS;
145
+ if (failures > 0 && blind) {
146
+ const reason = lastError instanceof Error ? lastError.message : String(lastError);
147
+ throw new AskError('POLL_FAILED', `Lost contact with the hwip server while waiting (${reason}). The user may have answered, or the question expired, without this call seeing it.`, eventId);
148
+ }
149
+ return null;
150
+ }
151
+ finally {
152
+ release();
153
+ sub?.dispose();
154
+ }
155
+ };
156
+ exports.pollForResponse = pollForResponse;