@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,45 @@
1
+ import type { DeviceRecord } from './types.js';
2
+ /** A resolved agent session: the listener's device id and its tmux name. */
3
+ export interface AgentTarget {
4
+ deviceId: string;
5
+ name: string;
6
+ /** `<deviceId>:<name>` — the stable key the app groups the session's chat by. */
7
+ key: string;
8
+ }
9
+ /**
10
+ * The target named nothing, named more than one session, or is on a machine
11
+ * that is offline. The message lists the keys to retry with.
12
+ */
13
+ export declare class AgentTargetError extends Error {
14
+ readonly code: 'UNKNOWN_TARGET' | 'AMBIGUOUS_TARGET' | 'TARGET_OFFLINE';
15
+ constructor(code: 'UNKNOWN_TARGET' | 'AMBIGUOUS_TARGET' | 'TARGET_OFFLINE', message: string);
16
+ }
17
+ /**
18
+ * Whether an agent may type into this session. Subagents are view-only (no
19
+ * input). A `hwip sh` session is a real shell: an agent typing into it would
20
+ * run commands on that machine, on another PC as easily as on its own.
21
+ */
22
+ export declare const isAgentTarget: (s: {
23
+ parentName?: string;
24
+ agentKind?: string;
25
+ }) => boolean;
26
+ /**
27
+ * Resolve `target` to one live agent session.
28
+ *
29
+ * The key `<deviceId>:<tmuxName>` is exact. Anything else is matched against
30
+ * the tmux name, the user's alias, the agent's own session name and the
31
+ * listener's label, and must match exactly one session: the same tmux name on
32
+ * two PCs is the routing bug a guess would reintroduce, so two matches are an
33
+ * error that lists both keys. The sender's own session (`selfKey`) is left out
34
+ * of name matching, so the same project on two PCs resolves to the other one;
35
+ * named alone, it resolves to itself for the caller to refuse.
36
+ *
37
+ * A session on an offline machine is refused rather than sent to: the listener
38
+ * takes commands over its socket only and fetches none it missed, so the
39
+ * message would be stored and never typed.
40
+ *
41
+ * The CLI keeps its own copy (cli/src/agent-target.ts): it depends on this
42
+ * package, but this package's entry point starts the server when imported.
43
+ */
44
+ export declare const resolveAgentTarget: (devices: DeviceRecord[], target: string, selfKey?: string) => AgentTarget;
45
+ //# sourceMappingURL=agent-target.d.ts.map
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveAgentTarget = exports.isAgentTarget = exports.AgentTargetError = void 0;
4
+ /**
5
+ * The target named nothing, named more than one session, or is on a machine
6
+ * that is offline. The message lists the keys to retry with.
7
+ */
8
+ class AgentTargetError extends Error {
9
+ code;
10
+ constructor(code, message) {
11
+ super(message);
12
+ this.code = code;
13
+ this.name = 'AgentTargetError';
14
+ }
15
+ }
16
+ exports.AgentTargetError = AgentTargetError;
17
+ /**
18
+ * Whether an agent may type into this session. Subagents are view-only (no
19
+ * input). A `hwip sh` session is a real shell: an agent typing into it would
20
+ * run commands on that machine, on another PC as easily as on its own.
21
+ */
22
+ const isAgentTarget = (s) => !s.parentName && s.agentKind !== 'shell';
23
+ exports.isAgentTarget = isAgentTarget;
24
+ const candidatesOf = (devices) => devices.flatMap((d) => (d.agentSessions ?? [])
25
+ .filter(exports.isAgentTarget)
26
+ .map((s) => ({
27
+ deviceId: d.deviceId,
28
+ name: s.name,
29
+ key: `${d.deviceId}:${s.name}`,
30
+ alias: d.agentSessionAliases?.[s.name],
31
+ label: s.label,
32
+ providerSessionName: s.providerSessionName,
33
+ host: d.nickname ?? d.deviceId,
34
+ offline: d.isOnline === false,
35
+ })));
36
+ const listCandidates = (list) => list.map((c) => `${c.key} (${c.alias ?? c.providerSessionName ?? c.label ?? c.name} on ${c.host})`).join(', ') || 'none';
37
+ /**
38
+ * Resolve `target` to one live agent session.
39
+ *
40
+ * The key `<deviceId>:<tmuxName>` is exact. Anything else is matched against
41
+ * the tmux name, the user's alias, the agent's own session name and the
42
+ * listener's label, and must match exactly one session: the same tmux name on
43
+ * two PCs is the routing bug a guess would reintroduce, so two matches are an
44
+ * error that lists both keys. The sender's own session (`selfKey`) is left out
45
+ * of name matching, so the same project on two PCs resolves to the other one;
46
+ * named alone, it resolves to itself for the caller to refuse.
47
+ *
48
+ * A session on an offline machine is refused rather than sent to: the listener
49
+ * takes commands over its socket only and fetches none it missed, so the
50
+ * message would be stored and never typed.
51
+ *
52
+ * The CLI keeps its own copy (cli/src/agent-target.ts): it depends on this
53
+ * package, but this package's entry point starts the server when imported.
54
+ */
55
+ const resolveAgentTarget = (devices, target, selfKey) => {
56
+ const all = candidatesOf(devices);
57
+ const toTarget = ({ deviceId, name, key, host, offline }) => {
58
+ if (offline) {
59
+ throw new AgentTargetError('TARGET_OFFLINE', `${key} is on ${host}, which is offline — the message would never be typed`);
60
+ }
61
+ return { deviceId, name, key };
62
+ };
63
+ const exact = all.find((c) => c.key === target);
64
+ if (exact)
65
+ return toTarget(exact);
66
+ const named = (c) => [c.name, c.alias, c.providerSessionName, c.label].some((n) => n === target);
67
+ const others = all.filter((c) => c.key !== selfKey);
68
+ const matches = others.filter(named);
69
+ if (matches.length === 1)
70
+ return toTarget(matches[0]);
71
+ if (matches.length === 0) {
72
+ const self = all.find((c) => c.key === selfKey && named(c));
73
+ if (self)
74
+ return toTarget(self);
75
+ }
76
+ if (matches.length > 1) {
77
+ throw new AgentTargetError('AMBIGUOUS_TARGET', `ambiguous target "${target}" — use one of these keys: ${listCandidates(matches)}`);
78
+ }
79
+ throw new AgentTargetError('UNKNOWN_TARGET', `unknown target "${target}" — other agent sessions: ${listCandidates(others)}`);
80
+ };
81
+ exports.resolveAgentTarget = resolveAgentTarget;
@@ -0,0 +1,162 @@
1
+ import type { McpServerConfig } from './config.js';
2
+ import type { PushResponse, HookTriggerResponse, HookEventResponse, DevicesResponse, DeviceKeyResponse, AgentSessionRenameResponse, PushListResponse, DismissResponse, ChannelsResponse, UploadRequestResponse, AttachedFile, LanDeliveredFile } from './types.js';
3
+ export declare class ApiError extends Error {
4
+ readonly code: string;
5
+ readonly status: number;
6
+ /** The request cannot have been sent: no connection was ever made. */
7
+ readonly unsent: boolean;
8
+ /** The transport's own code for a call that got no reply (`ECONNRESET`), when it gave one. */
9
+ readonly causeCode?: string;
10
+ /** A reply this client would not read to the end: the server answered, whatever it said. */
11
+ readonly unreadReply: boolean;
12
+ constructor(message: string, code: string, status: number, transport?: {
13
+ unsent?: boolean;
14
+ causeCode?: string;
15
+ unreadReply?: boolean;
16
+ });
17
+ }
18
+ /**
19
+ * A failed call that a later one may well get past: the network (NETWORK_ERROR,
20
+ * status 0), a reply that could not be read (BAD_RESPONSE, status 0), the
21
+ * request's own timeout, a rate limit, a server error. A 4xx
22
+ * other than 408 and 429 is an answer about the request itself (bad key, hook
23
+ * gone) and will say the same thing every time — and so does a refused redirect
24
+ * (REDIRECT_REFUSED, also status 0): the base URL sends the request elsewhere
25
+ * on every call. Nor does a base URL that is not an address get to be one.
26
+ */
27
+ export declare const isTransient: (err: unknown) => boolean;
28
+ /** The time a client waits by. Injected so a test does not sit out the pauses. */
29
+ export interface Clock {
30
+ now: () => number;
31
+ /** Rejects with the signal's reason when it aborts first. */
32
+ sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
33
+ }
34
+ /**
35
+ * The largest file any tier can send — Pro's 1 GiB (`fileMaxBytes` in hwip's
36
+ * shared tier-limits). The account's own tier still decides below this; the cap
37
+ * only stops a transfer no tier could accept from being read into memory.
38
+ */
39
+ export declare const MAX_FILE_BYTES = 1073741824;
40
+ /**
41
+ * The most a JSON reply (or an error body) may weigh. The API's own replies are
42
+ * a few KiB, a push list tens; 8 MiB is far past any real one and still small
43
+ * enough that a wrong or hostile host cannot make this process hold gigabytes.
44
+ */
45
+ export declare const MAX_RESPONSE_BYTES: number;
46
+ /**
47
+ * What a one-line message cannot carry: control characters (Cc), the invisible
48
+ * format characters (Cf — bidi marks and overrides, zero-width space, the BOM,
49
+ * and the Unicode tag block that can hide text from a reader but not from a
50
+ * model), the grapheme-join blocker U+034F, and the Hangul fillers (U+115F,
51
+ * U+1160, U+3164, U+FFA0 — blank letters an attacker would use as an invisible
52
+ * base character), and line and paragraph separators. U+200C ZWNJ and U+200D ZWJ
53
+ * are writing, not hiding (Persian, Hindi, emoji sequences), so a lone one
54
+ * stays — but a run of them is a bit channel, so a run collapses to its first
55
+ * character (C153). Selectors go by allowlist, not by run: U+FE0E/U+FE0F stay
56
+ * only after an emoji base (text/emoji presentation), U+E0100–E01EF only after a
57
+ * unified ideograph (IVS); every other selector — including the Mongolian FVS
58
+ * (U+180B–U+180D, U+180F) — goes, so spacing a run out with an invisible
59
+ * character no longer smuggles it past the rule. The same rule as cli's
60
+ * `UNPRINTABLE` block (src/http-failure.ts): the lines below from
61
+ * `const UNPRINTABLE` through `sanitizeSmuggled` are character-identical there.
62
+ */
63
+ export declare const UNPRINTABLE: RegExp;
64
+ /**
65
+ * The server's own wording, made safe to hand to the model: one line, control
66
+ * characters gone, cut to a fixed length. A reply from a hijacked or mistaken
67
+ * host could otherwise put a paragraph of instructions into the tool result.
68
+ */
69
+ export declare const serverMessage: (value: unknown, fallback: string) => string;
70
+ export declare const readCappedText: (response: Response, max: number) => Promise<string>;
71
+ /** A file transfer's timeout: the usual request budget plus time for its bytes. */
72
+ export declare const transferTimeoutMs: (bytes: number) => number;
73
+ /**
74
+ * The per-request timeout, ended early by `outer` (the tool call's own abort)
75
+ * when one is given. Hand-rolled because `AbortSignal.any` needs Node 20.3 and
76
+ * the package supports 18.17. Each signal aborts with its own reason, so a
77
+ * timeout still reads as `TimeoutError` and a cancel as `AbortError`.
78
+ * `release` drops the listener on `outer` once the request has settled.
79
+ * Exported for tests.
80
+ */
81
+ export declare const requestSignal: (timeoutMs: number, outer?: AbortSignal) => {
82
+ signal: AbortSignal;
83
+ release: () => void;
84
+ };
85
+ export declare class HwipApiClient {
86
+ private readonly clock;
87
+ private readonly apiKey;
88
+ private readonly baseUrl;
89
+ constructor(config: McpServerConfig, clock?: Clock);
90
+ sendPush(params: {
91
+ title?: string;
92
+ body?: string;
93
+ url?: string;
94
+ type?: string;
95
+ priority?: string;
96
+ targetDeviceId?: string;
97
+ /** With `type: 'agent.command'`: the tmux session on `targetDeviceId` to type the body into. */
98
+ agentSessionName?: string;
99
+ channelId?: string;
100
+ sessionId?: string;
101
+ files?: (AttachedFile | LanDeliveredFile)[];
102
+ isEncrypted?: boolean;
103
+ senderPublicKey?: string;
104
+ deviceKeyMap?: Record<string, string>;
105
+ }): Promise<PushResponse>;
106
+ triggerHook(hookId: string, params: {
107
+ title?: string;
108
+ body?: string;
109
+ actions?: {
110
+ id: string;
111
+ label?: string;
112
+ style?: string;
113
+ }[];
114
+ /** A sealed question: `body`, `senderPublicKey` and `deviceKeyMap` hold the envelope (ask-seal.ts). */
115
+ isEncrypted?: true;
116
+ senderPublicKey?: string;
117
+ deviceKeyMap?: Record<string, string>;
118
+ timeout?: number;
119
+ fallback?: string;
120
+ metadata?: Record<string, unknown>;
121
+ hookType?: 'combo';
122
+ /** This ask settles an `exitRemote` answer (tools/ask.ts). */
123
+ acceptsExit?: boolean;
124
+ files?: AttachedFile[];
125
+ sessionId?: string;
126
+ /** Stable session grouping so the hook joins the agent chat. */
127
+ agentDeviceId?: string;
128
+ agentSessionName?: string;
129
+ }, signal?: AbortSignal,
130
+ /**
131
+ * Sending again: for how long from now the question is still worth it, and who to tell before
132
+ * each wait (with how many there have been). Without it, one try.
133
+ */
134
+ retry?: {
135
+ withinMs: number;
136
+ onRetry?: (tries: number) => Promise<void>;
137
+ }): Promise<HookTriggerResponse>;
138
+ getHookEvent(hookId: string, eventId: string): Promise<HookEventResponse>;
139
+ listDevices(): Promise<DevicesResponse>;
140
+ /** Put this machine's key on the account's device list; `nickname` names it only when the server creates the entry. */
141
+ registerDeviceKey(deviceId: string, publicKey: string, nickname: string): Promise<DeviceKeyResponse>;
142
+ /** Set (or clear, when `alias` is empty) the display name for an agent
143
+ * session, keyed by its tmux `name` on `deviceId`. */
144
+ renameAgentSession(deviceId: string, name: string, alias: string): Promise<AgentSessionRenameResponse>;
145
+ listPushes(params?: {
146
+ limit?: number;
147
+ type?: string;
148
+ }): Promise<PushListResponse>;
149
+ dismissPush(pushId: string): Promise<DismissResponse>;
150
+ dismissAllPushes(): Promise<DismissResponse>;
151
+ listChannels(): Promise<ChannelsResponse>;
152
+ requestUpload(params: {
153
+ fileName: string;
154
+ fileType: string;
155
+ fileSize: number;
156
+ }, signal?: AbortSignal): Promise<UploadRequestResponse>;
157
+ uploadToS3(url: string, content: string | Buffer, contentType: string, signal?: AbortSignal): Promise<void>;
158
+ private request;
159
+ /** One request, its JSON body already the bytes to send. */
160
+ private send;
161
+ }
162
+ //# sourceMappingURL=api-client.d.ts.map