@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,147 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.nextCommandNumber = void 0;
4
+ const node_fs_1 = require("node:fs");
5
+ const node_crypto_1 = require("node:crypto");
6
+ const node_os_1 = require("node:os");
7
+ const node_path_1 = require("node:path");
8
+ const FILE = 'command-seq';
9
+ const LOCK = 'command-seq.lock';
10
+ /** 40 tries 50 ms apart: two seconds, then the send is refused. */
11
+ const LOCK_TRIES = 40;
12
+ const LOCK_RETRY_MS = 50;
13
+ /** A lock older than this was left by a process that died holding it. */
14
+ const LOCK_STALE_MS = 5000;
15
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
16
+ /**
17
+ * One try at the lock: its release when taken, 'held' when someone else has it, 'failed' when it cannot be made at all.
18
+ * The lock holds a token only its taker knows, and the release removes the file only while it still holds that token:
19
+ * a holder that was stopped for longer than `LOCK_STALE_MS` (a laptop asleep) finds its lock cleared and perhaps taken
20
+ * again, and must not remove the new holder's.
21
+ */
22
+ const tryLock = (path) => {
23
+ const token = (0, node_crypto_1.randomBytes)(8).toString('hex');
24
+ let fd;
25
+ try {
26
+ fd = (0, node_fs_1.openSync)(path, 'wx', 0o600);
27
+ }
28
+ catch (err) {
29
+ // err.code is how node names a failed open. Nothing was created, so nothing here is ours to remove.
30
+ return err.code === 'EEXIST' ? 'held' : 'failed';
31
+ }
32
+ try {
33
+ (0, node_fs_1.writeSync)(fd, token);
34
+ }
35
+ catch {
36
+ try {
37
+ (0, node_fs_1.unlinkSync)(path); // created but not written (disk full): no later sender should wait on an empty lock
38
+ }
39
+ catch { /* gone already */ }
40
+ return 'failed';
41
+ }
42
+ finally {
43
+ (0, node_fs_1.closeSync)(fd);
44
+ }
45
+ return () => {
46
+ try {
47
+ if ((0, node_fs_1.readFileSync)(path, 'utf-8') === token)
48
+ (0, node_fs_1.unlinkSync)(path);
49
+ }
50
+ catch { /* gone already: nothing is held */ }
51
+ };
52
+ };
53
+ /**
54
+ * The lock file's own age says whether anyone is still behind it. A stale one is moved aside under a name of its own
55
+ * and only then checked and removed: a plain unlink by path could remove a lock another waiter had just made. If what
56
+ * was moved is not the file that was seen, it was a live lock; it goes back unless yet another has been made since.
57
+ */
58
+ const removeIfStale = (path) => {
59
+ try {
60
+ const seen = (0, node_fs_1.statSync)(path);
61
+ if (Date.now() - seen.mtimeMs <= LOCK_STALE_MS)
62
+ return;
63
+ const aside = `${path}.${process.pid}.${(0, node_crypto_1.randomBytes)(6).toString('hex')}.stale`;
64
+ (0, node_fs_1.renameSync)(path, aside);
65
+ try {
66
+ const moved = (0, node_fs_1.statSync)(aside);
67
+ if (moved.ino !== seen.ino || moved.mtimeMs !== seen.mtimeMs) {
68
+ try {
69
+ (0, node_fs_1.linkSync)(aside, path);
70
+ }
71
+ catch { /* someone made a lock since: theirs stands */ }
72
+ }
73
+ }
74
+ finally {
75
+ (0, node_fs_1.unlinkSync)(aside);
76
+ }
77
+ }
78
+ catch { /* taken, or removed, by someone else in between */ }
79
+ };
80
+ /** The last number handed out: 0 when there is no file or it holds no number, null when it cannot be read or is out of range. */
81
+ const readLast = (path) => {
82
+ let text;
83
+ try {
84
+ text = (0, node_fs_1.readFileSync)(path, 'utf-8');
85
+ }
86
+ catch (err) {
87
+ return err.code === 'ENOENT' ? 0 : null; // err.code is how node names a missing file
88
+ }
89
+ const line = text.trim();
90
+ if (!/^\d+$/.test(line))
91
+ return 0;
92
+ const last = Number(line);
93
+ return Number.isSafeInteger(last) ? last : null;
94
+ };
95
+ /** Read, add, write: runs with the lock held, and without a pause, so callers in one process never overlap. */
96
+ const draw = (dir, now) => {
97
+ const path = (0, node_path_1.join)(dir, FILE);
98
+ const last = readLast(path);
99
+ if (last === null)
100
+ return { refused: 'io' };
101
+ const n = Math.max(last + 1, Math.floor(now()));
102
+ if (!Number.isSafeInteger(n))
103
+ return { refused: 'io' };
104
+ // Random name, exclusive create, then rename: a reader never sees half a line.
105
+ const temp = `${path}.${process.pid}.${(0, node_crypto_1.randomBytes)(6).toString('hex')}.tmp`;
106
+ try {
107
+ (0, node_fs_1.writeFileSync)(temp, `${n}\n`, { mode: 0o600, flag: 'wx' });
108
+ (0, node_fs_1.renameSync)(temp, path);
109
+ }
110
+ catch {
111
+ try {
112
+ (0, node_fs_1.unlinkSync)(temp);
113
+ }
114
+ catch { /* never made */ }
115
+ return { refused: 'io' };
116
+ }
117
+ return n;
118
+ };
119
+ const nextCommandNumber = async (deps = {}) => {
120
+ const dir = deps.dir ?? (0, node_path_1.join)((0, node_os_1.homedir)(), '.hwip');
121
+ const now = deps.now ?? Date.now;
122
+ const sleep = deps.sleep ?? defaultSleep;
123
+ try {
124
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true, mode: 0o700 });
125
+ }
126
+ catch {
127
+ return { refused: 'io' };
128
+ }
129
+ const lock = (0, node_path_1.join)(dir, LOCK);
130
+ for (let attempt = 0; attempt < LOCK_TRIES; attempt += 1) {
131
+ const taken = tryLock(lock);
132
+ if (taken === 'failed')
133
+ return { refused: 'io' };
134
+ if (typeof taken === 'function') {
135
+ try {
136
+ return draw(dir, now);
137
+ }
138
+ finally {
139
+ taken();
140
+ }
141
+ }
142
+ removeIfStale(lock);
143
+ await sleep(LOCK_RETRY_MS);
144
+ }
145
+ return { refused: 'lock' };
146
+ };
147
+ exports.nextCommandNumber = nextCommandNumber;
@@ -0,0 +1,93 @@
1
+ export interface McpServerConfig {
2
+ apiKey: string;
3
+ baseUrl: string;
4
+ /** WebSocket endpoint for the hook-response fast path (§S3). Optional —
5
+ * without it (or without a WebSocket global) waits are poll-only. */
6
+ wsUrl?: string;
7
+ hookId?: string;
8
+ deviceId?: string;
9
+ sessionId?: string;
10
+ /** Stable agent-session key parts so hook pushes join the session chat:
11
+ * the listener's per-host device id (mirrors cli computeListenerDeviceId)
12
+ * + the tmux session name. The Claude sessionId above rotates on
13
+ * compact/resume; these don't. */
14
+ agentDeviceId?: string;
15
+ agentSessionName?: string;
16
+ /** Last path segment of the project directory — prefixed onto push titles. */
17
+ projectName: string;
18
+ }
19
+ /** The project directory the agent runs in, across supported agents. */
20
+ /**
21
+ * The key every per-project hwip file is named by. Shelling out to `cksum`
22
+ * (rather than a pure-JS CRC) is what guarantees it matches the files the bash
23
+ * hooks have already written — they key off `printf '%s' "$dir" | cksum`. Null
24
+ * when `cksum` is unavailable, which means no per-project file can be resolved.
25
+ */
26
+ export declare const projectHash: (dir: string) => string | null;
27
+ export declare const detectProjectDir: () => string;
28
+ /**
29
+ * Prefix a push title with the project name so the device feed stays
30
+ * scannable — "hwip · Build finished" instead of a bare "Build finished".
31
+ * Idempotent: a title already carrying the project segment is returned
32
+ * unchanged (guards against double-prefixing on retries).
33
+ */
34
+ export declare const formatPushTitle: (projectName: string, title: string) => string;
35
+ /**
36
+ * Why this machine should not trust the config file as it stands, or null — the
37
+ * rule of the cli's `configModeProblem` (src/config.ts), keep the two in step.
38
+ * The file holds the API key AND says where the key is sent (`baseUrl`, `wsUrl`),
39
+ * so one that another user owns, or that anyone can write, is a way to redirect
40
+ * the key. `refuse`: ignore the file; otherwise only warn. Group access to a file
41
+ * the user owns is not refused: a user-private group (umask 002, a dotfiles
42
+ * checkout) makes every file 664, and ignoring those would stop every session.
43
+ * Root may read what a user owns (sudo). This server only reads the file, so
44
+ * unlike the cli it does not narrow the mode itself.
45
+ */
46
+ export declare const configModeProblem: (st: {
47
+ uid: number;
48
+ mode: number;
49
+ }, myUid: number, file?: string) => {
50
+ refuse: boolean;
51
+ why: string;
52
+ } | null;
53
+ /**
54
+ * The directory name Claude Code gives a cwd under `~/.claude/projects/`.
55
+ * Copied from the Claude Code 2.1.287 binary (`k` / `Jx`): every character
56
+ * outside `[a-zA-Z0-9]` becomes `-`, so `.`, `_`, spaces, `@` and Hangul all
57
+ * collapse, one `-` per UTF-16 unit. Names past 200 characters are cut and
58
+ * suffixed with `-` plus the base-36 of a Java-style string hash of the cwd.
59
+ * Same rule and test vectors as the cli's `claudeProjectDirName`
60
+ * (cli/src/remote-agents.ts) — keep the two in step.
61
+ */
62
+ export declare const claudeProjectDirName: (cwd: string) => string;
63
+ export declare const loadConfig: () => McpServerConfig;
64
+ /** Listener device id — MUST match cli `computeListenerDeviceId` so `hwip_ask`
65
+ * hooks land under the same agent-session key as the listener's
66
+ * agent.command/state pushes (otherwise they reach the feed but never thread
67
+ * into the agent chat). cli hashes the platform machine id first and falls
68
+ * back to a hostname hash pinned in a sticky file; mirror that order, reading
69
+ * (never writing) the file so both processes resolve the same id. */
70
+ export declare const listenerDeviceId: () => string;
71
+ /** Shells out; returns null instead of throwing so every caller reads as one
72
+ * expression. */
73
+ export type CommandRunner = (cmd: string, args: string[]) => string | null;
74
+ /** The tmux session name the agent runs in (`hwip-<project>`) — stable half of
75
+ * the session key. Undefined outside tmux; the hook then stays feed-only. */
76
+ export declare const detectTmuxSessionName: (run?: CommandRunner) => string | undefined;
77
+ /**
78
+ * A one-line notice for a machine that still exports `HWIP_WS_URL`, or null.
79
+ *
80
+ * The variable is no longer read. Dropping it in silence would repeat the
81
+ * failure it caused — a value that looks like it is in effect while something
82
+ * else decides — so it is read here only to say it is being ignored.
83
+ */
84
+ export declare const legacyWsEnv: (env?: NodeJS.ProcessEnv) => string | undefined;
85
+ /**
86
+ * What to tell a machine that still exports `HWIP_WS_URL`, or null. Two
87
+ * messages, because the two situations differ: an export beside a config file
88
+ * is dead weight, while an export on its own is still carrying the value.
89
+ */
90
+ export declare const legacyWsEnvNotice: (fileConfig?: {
91
+ wsUrl?: string;
92
+ }, env?: NodeJS.ProcessEnv) => string | null;
93
+ //# sourceMappingURL=config.d.ts.map