@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.
- package/LICENSE +190 -0
- package/NOTICE +10 -0
- package/README.md +413 -2
- package/dist/agent-target.d.ts +45 -0
- package/dist/agent-target.js +81 -0
- package/dist/api-client.d.ts +162 -0
- package/dist/api-client.js +517 -0
- package/dist/ask-context-attach.d.ts +67 -0
- package/dist/ask-context-attach.js +147 -0
- package/dist/ask-seal.d.ts +118 -0
- package/dist/ask-seal.js +194 -0
- package/dist/command-seal.d.ts +49 -0
- package/dist/command-seal.js +90 -0
- package/dist/command-seq.d.ts +30 -0
- package/dist/command-seq.js +147 -0
- package/dist/config.d.ts +93 -0
- package/dist/config.js +561 -0
- package/dist/crypto.d.ts +234 -0
- package/dist/crypto.js +606 -0
- package/dist/error-format.d.ts +7 -0
- package/dist/error-format.js +77 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +30 -0
- package/dist/key-changes.d.ts +50 -0
- package/dist/key-changes.js +163 -0
- package/dist/mime.d.ts +16 -0
- package/dist/mime.js +58 -0
- package/dist/peer-key.d.ts +36 -0
- package/dist/peer-key.js +35 -0
- package/dist/poll.d.ts +24 -0
- package/dist/poll.js +156 -0
- package/dist/push-audience.d.ts +35 -0
- package/dist/push-audience.js +64 -0
- package/dist/redirect.d.ts +8 -0
- package/dist/redirect.js +17 -0
- package/dist/remote-state.d.ts +38 -0
- package/dist/remote-state.js +81 -0
- package/dist/resources/channels.d.ts +4 -0
- package/dist/resources/channels.js +24 -0
- package/dist/resources/devices.d.ts +4 -0
- package/dist/resources/devices.js +24 -0
- package/dist/sanitize.d.ts +41 -0
- package/dist/sanitize.js +106 -0
- package/dist/self-register.d.ts +34 -0
- package/dist/self-register.js +51 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +63 -0
- package/dist/tools/agent-local.d.ts +17 -0
- package/dist/tools/agent-local.js +139 -0
- package/dist/tools/agent-send.d.ts +6 -0
- package/dist/tools/agent-send.js +89 -0
- package/dist/tools/ask.d.ts +9 -0
- package/dist/tools/ask.js +275 -0
- package/dist/tools/broadcast.d.ts +5 -0
- package/dist/tools/broadcast.js +42 -0
- package/dist/tools/clipboard.d.ts +5 -0
- package/dist/tools/clipboard.js +47 -0
- package/dist/tools/dismiss.d.ts +5 -0
- package/dist/tools/dismiss.js +49 -0
- package/dist/tools/file.d.ts +5 -0
- package/dist/tools/file.js +169 -0
- package/dist/tools/list.d.ts +4 -0
- package/dist/tools/list.js +44 -0
- package/dist/tools/lookup.d.ts +10 -0
- package/dist/tools/lookup.js +45 -0
- package/dist/tools/notify.d.ts +5 -0
- package/dist/tools/notify.js +118 -0
- package/dist/tools/rename.d.ts +5 -0
- package/dist/tools/rename.js +45 -0
- package/dist/trust-pins.d.ts +70 -0
- package/dist/trust-pins.js +269 -0
- package/dist/types.d.ts +157 -0
- package/dist/types.js +2 -0
- package/dist/url-check.d.ts +11 -0
- package/dist/url-check.js +57 -0
- package/dist/ws-wait.d.ts +72 -0
- package/dist/ws-wait.js +200 -0
- 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;
|
package/dist/config.d.ts
ADDED
|
@@ -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
|