mishmar 0.0.0-stage → 0.2.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/dist/signer.js ADDED
@@ -0,0 +1,392 @@
1
+ /**
2
+ * The signer as a child process, or one already running.
3
+ *
4
+ * `Signer` starts `mishmar signer run --local …` with the agent's config and
5
+ * talks to it over a Unix socket (or, on Windows, localhost with a token).
6
+ * The child holds the agent's key and its halves of the venue keys; this
7
+ * process holds no secret at all. An order is an intent: the child turns it
8
+ * into a proposal signed with the agent's key, sends it one-shot where it can
9
+ * build the venue's message, through the channel otherwise, and hands back
10
+ * the decision as the engine recorded it. The agent's reads pass through the
11
+ * child the same way.
12
+ *
13
+ * const s = await new Signer().start(); // ~/.mishmar/agent.json, mishmar on PATH
14
+ * let d = await s.order({ tradingAccountId: "ta_…", symbol: "BTC", side: "buy", size: "0.001", price: "79500" });
15
+ * if (d.verdict === "require_approval") d = await s.wait(d.id);
16
+ * await s.stop();
17
+ *
18
+ * The child lives and dies with this process: it gets a pipe on stdin that
19
+ * is never written to and stops when the pipe closes, so it does not outlive
20
+ * a bot that crashed or was killed. A signer run on its own instead (systemd,
21
+ * launchd, Docker: `mishmar signer run --local unix:/path`) is reached with
22
+ * `Signer.connect`, which never starts or stops it; that is the way for
23
+ * several processes of one agent, or to keep the signer connected across bot
24
+ * restarts. One signer runs per config: a second one on the same config
25
+ * stops at once and says which process holds it.
26
+ *
27
+ * The binary is found through `MISHMAR_BIN`, the PATH, or
28
+ * `~/.mishmar/bin/<version>/mishmar`; `downloadBinary` fetches a release from
29
+ * GitHub and checks it against the release's checksums.
30
+ */
31
+ import { execFile, spawn } from "node:child_process";
32
+ import { createHash, randomBytes } from "node:crypto";
33
+ import { constants as fsConstants } from "node:fs";
34
+ import { access, chmod, mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
35
+ import * as http from "node:http";
36
+ import * as net from "node:net";
37
+ import { arch, homedir, platform, tmpdir } from "node:os";
38
+ import * as path from "node:path";
39
+ import { EngineError, resolved } from "./client.js";
40
+ export const RELEASES = "https://github.com/mishmar-io/mishmar/releases/download";
41
+ const ENV_BINARY = "MISHMAR_BIN";
42
+ const ENV_LOCAL_TOKEN = "MISHMAR_LOCAL_TOKEN";
43
+ /** The release asset's os_arch for this machine: linux_amd64, darwin_arm64, … */
44
+ export function platformName() {
45
+ const os = { linux: "linux", darwin: "darwin", win32: "windows" }[platform()];
46
+ const cpu = { x64: "amd64", arm64: "arm64" }[arch()];
47
+ if (!os || !cpu)
48
+ throw new Error(`no mishmar release for ${platform()}/${arch()}`);
49
+ return `${os}_${cpu}`;
50
+ }
51
+ function exe(name) {
52
+ return platform() === "win32" ? name + ".exe" : name;
53
+ }
54
+ async function executable(p) {
55
+ if (!p)
56
+ return false;
57
+ try {
58
+ await access(p, fsConstants.X_OK);
59
+ return true;
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ }
65
+ /**
66
+ * Where the mishmar binary is: the argument, `MISHMAR_BIN`, the PATH, or a
67
+ * release `downloadBinary` fetched. Throws with what to do when there is none.
68
+ */
69
+ export async function findBinary(explicit) {
70
+ const candidates = [explicit, process.env[ENV_BINARY]];
71
+ const name = exe("mishmar");
72
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter))
73
+ if (dir)
74
+ candidates.push(path.join(dir, name));
75
+ const home = path.join(homedir(), ".mishmar", "bin");
76
+ try {
77
+ for (const version of (await readdir(home)).sort().reverse())
78
+ candidates.push(path.join(home, version, name));
79
+ }
80
+ catch {
81
+ // no downloads yet
82
+ }
83
+ for (const c of candidates)
84
+ if (await executable(c))
85
+ return c;
86
+ throw new Error("no mishmar binary: install it from https://github.com/mishmar-io/mishmar/releases (or call downloadBinary(version)), put it on the PATH or in MISHMAR_BIN");
87
+ }
88
+ /**
89
+ * Fetch the release `version` (a tag, v0.1.0) for this machine into `destDir`
90
+ * (default `~/.mishmar/bin/<version>`), after checking its SHA-256 against the
91
+ * release's SHA256SUMS. Returns the binary's path.
92
+ */
93
+ export async function downloadBinary(version, destDir, fetchImpl = fetch) {
94
+ const name = `mishmar_${version}_${platformName()}`;
95
+ const base = `${RELEASES}/${version}`;
96
+ const sumsRes = await fetchImpl(`${base}/SHA256SUMS`);
97
+ if (!sumsRes.ok)
98
+ throw new Error(`${base}/SHA256SUMS: HTTP ${sumsRes.status}`);
99
+ let want;
100
+ for (const line of (await sumsRes.text()).split("\n")) {
101
+ const parts = line.trim().split(/\s+/);
102
+ if (parts.length === 2 && parts[1]?.replace(/^\*/, "") === `${name}.tar.gz`)
103
+ want = parts[0]?.toLowerCase();
104
+ }
105
+ if (!want)
106
+ throw new Error(`${name}.tar.gz is not in the release's SHA256SUMS`);
107
+ const res = await fetchImpl(`${base}/${name}.tar.gz`);
108
+ if (!res.ok)
109
+ throw new Error(`${base}/${name}.tar.gz: HTTP ${res.status}`);
110
+ const archive = Buffer.from(await res.arrayBuffer());
111
+ const got = createHash("sha256").update(archive).digest("hex");
112
+ if (got !== want)
113
+ throw new Error(`${name}.tar.gz: checksum ${got} is not the release's ${want}`);
114
+ const dir = destDir ?? path.join(homedir(), ".mishmar", "bin", version);
115
+ await mkdir(dir, { recursive: true, mode: 0o700 });
116
+ const work = await mkdtemp(path.join(tmpdir(), "mishmar-"));
117
+ try {
118
+ const file = path.join(work, `${name}.tar.gz`);
119
+ await writeFile(file, archive);
120
+ await new Promise((resolve, reject) => execFile("tar", ["-xzf", file, "-C", dir, exe("mishmar")], (err) => (err ? reject(err) : resolve())));
121
+ }
122
+ finally {
123
+ await rm(work, { recursive: true, force: true });
124
+ }
125
+ const out = path.join(dir, exe("mishmar"));
126
+ await chmod(out, 0o755);
127
+ return out;
128
+ }
129
+ /**
130
+ * The agent's signer, run as a child process and spoken to over its local API.
131
+ * The child stops when this process exits, however it exits. `Signer.connect`
132
+ * speaks to a signer that runs on its own instead.
133
+ */
134
+ export class Signer {
135
+ options;
136
+ address;
137
+ token;
138
+ process;
139
+ #dir;
140
+ #stderrTail = [];
141
+ #attached = false;
142
+ #ready = false;
143
+ constructor(options = {}) {
144
+ this.options = options;
145
+ this.address = options.address;
146
+ }
147
+ /**
148
+ * Speak to a signer that already runs on its own (`mishmar signer run
149
+ * --local ADDRESS`, kept up by systemd, launchd or Docker), and wait until it
150
+ * answers. `address` is `unix:/path`, or a loopback host:port with the
151
+ * signer's token. Nothing is started, and `stop` leaves the signer running.
152
+ */
153
+ static async connect(address, options = {}) {
154
+ const s = new Signer({ address, startTimeoutMs: options.startTimeoutMs, timeoutMs: options.timeoutMs });
155
+ s.#attached = true;
156
+ if (!address.startsWith("unix:")) {
157
+ s.token = options.token ?? process.env[ENV_LOCAL_TOKEN];
158
+ if (!s.token)
159
+ throw new Error(`${address}: a signer on a TCP port needs its token: pass { token } or set ${ENV_LOCAL_TOKEN}`);
160
+ }
161
+ return s.start();
162
+ }
163
+ /** Start the child and wait until its local API answers; for a signer from `connect`, only wait until it answers. */
164
+ async start() {
165
+ if (this.#attached) {
166
+ if (!this.#ready) {
167
+ await this.#waitReady(() => undefined);
168
+ this.#ready = true;
169
+ }
170
+ return this;
171
+ }
172
+ if (this.process)
173
+ return this;
174
+ const binary = await findBinary(this.options.binary);
175
+ const env = { ...process.env };
176
+ if (!this.address) {
177
+ if (platform() === "win32") {
178
+ this.address = `127.0.0.1:${await freePort()}`;
179
+ }
180
+ else {
181
+ this.#dir = await mkdtemp(path.join(tmpdir(), "mishmar-"));
182
+ this.address = "unix:" + path.join(this.#dir, "signer.sock");
183
+ }
184
+ }
185
+ if (!this.address.startsWith("unix:")) {
186
+ this.token ??= randomBytes(32).toString("base64url");
187
+ env[ENV_LOCAL_TOKEN] = this.token;
188
+ }
189
+ // The child stops when its stdin closes: when this process exits, however
190
+ // it exits. Nothing is ever written to it.
191
+ const args = ["signer", "run", "--local", this.address, "--log-level", this.options.logLevel ?? "info", "--exit-on-stdin-close"];
192
+ if (this.options.config)
193
+ args.push("--config", this.options.config);
194
+ const child = spawn(binary, args, { env, stdio: ["pipe", "ignore", "pipe"] });
195
+ child.stdin?.on("error", () => { }); // a child that exits first may close it under us
196
+ this.process = child;
197
+ let exited;
198
+ child.on("exit", (code, signal) => {
199
+ exited = { code, signal };
200
+ });
201
+ const onLog = this.options.onLog ?? ((line) => process.stderr.write(line + "\n"));
202
+ let pending = "";
203
+ child.stderr?.on("data", (chunk) => {
204
+ pending += chunk.toString("utf8");
205
+ const lines = pending.split("\n");
206
+ pending = lines.pop() ?? "";
207
+ for (const line of lines) {
208
+ this.#stderrTail.push(line);
209
+ if (this.#stderrTail.length > 40)
210
+ this.#stderrTail.shift();
211
+ onLog(line);
212
+ }
213
+ });
214
+ await this.#waitReady(() => exited);
215
+ return this;
216
+ }
217
+ async #waitReady(exitedOf) {
218
+ const deadline = Date.now() + (this.options.startTimeoutMs ?? 15_000);
219
+ for (;;) {
220
+ const exited = exitedOf();
221
+ if (exited) {
222
+ const tail = this.#stderrTail.join("\n");
223
+ await this.#cleanup();
224
+ throw new Error(`the signer exited with status ${exited.code ?? exited.signal} before it was ready:\n${tail}`);
225
+ }
226
+ try {
227
+ await this.#request("GET", "/health", { timeoutMs: 2_000 });
228
+ return;
229
+ }
230
+ catch (err) {
231
+ if (err instanceof EngineError)
232
+ throw err;
233
+ if (Date.now() >= deadline) {
234
+ await this.stop();
235
+ throw new Error(`the signer did not answer on ${this.address} within ${this.options.startTimeoutMs ?? 15_000} ms`);
236
+ }
237
+ await new Promise((r) => setTimeout(r, 100));
238
+ }
239
+ }
240
+ }
241
+ /**
242
+ * Stop the child, gently then not: its stdin closes, which stops it, and it
243
+ * gets SIGTERM, then SIGKILL after 5 s. A signer from `connect` keeps
244
+ * running; this only forgets it was ready.
245
+ */
246
+ async stop() {
247
+ if (this.#attached) {
248
+ this.#ready = false;
249
+ return;
250
+ }
251
+ const child = this.process;
252
+ if (!child)
253
+ return;
254
+ try {
255
+ child.stdin?.destroy();
256
+ if (child.exitCode === null && child.signalCode === null) {
257
+ const gone = new Promise((resolve) => child.once("exit", () => resolve()));
258
+ child.kill("SIGTERM");
259
+ const timer = new Promise((resolve) => setTimeout(() => resolve("timeout"), 5_000));
260
+ if ((await Promise.race([gone, timer])) === "timeout") {
261
+ child.kill("SIGKILL");
262
+ await gone;
263
+ }
264
+ }
265
+ }
266
+ finally {
267
+ await this.#cleanup();
268
+ }
269
+ }
270
+ async #cleanup() {
271
+ this.process = undefined;
272
+ if (this.#dir) {
273
+ await rm(this.#dir, { recursive: true, force: true });
274
+ this.#dir = undefined;
275
+ this.address = undefined;
276
+ }
277
+ }
278
+ // Proposals
279
+ /** Send an intent (the local API's ProposeRequest) and get the decision back. */
280
+ async propose(intent) {
281
+ return (await this.#request("POST", "/propose", { body: intent }));
282
+ }
283
+ /** Propose an order. A limit order needs a price; a market order is priced at the engine's mark. */
284
+ async order(intent) {
285
+ return this.propose({ orderType: "limit", reduceOnly: false, validForSeconds: 60, channel: false, ...intent });
286
+ }
287
+ /** Cancel a resting order this agent placed, by the clientOrderId its decision carries. */
288
+ async cancel(tradingAccountId, symbol, clientOrderId, validForSeconds = 60) {
289
+ return this.propose({ tradingAccountId, symbol, kind: "cancel", targetClientOrderId: clientOrderId, validForSeconds });
290
+ }
291
+ // Reads, through the child
292
+ async health() {
293
+ return (await this.#request("GET", "/health"));
294
+ }
295
+ async decision(decisionId) {
296
+ return (await this.#request("GET", `/decisions/${encodeURIComponent(decisionId)}`));
297
+ }
298
+ async decisions(query = {}) {
299
+ return (await this.#request("GET", "/decisions", { query }));
300
+ }
301
+ async approvals(status) {
302
+ return (await this.#request("GET", "/approvals", { query: { status } }));
303
+ }
304
+ async positions() {
305
+ return (await this.#request("GET", "/positions"));
306
+ }
307
+ async pnl(query = {}) {
308
+ return (await this.#request("GET", "/pnl", { query }));
309
+ }
310
+ async tradingAccount(tradingAccountId) {
311
+ return (await this.#request("GET", `/trading-accounts/${encodeURIComponent(tradingAccountId)}`));
312
+ }
313
+ async venueRules(tradingAccountId, symbol) {
314
+ return (await this.#request("GET", `/trading-accounts/${encodeURIComponent(tradingAccountId)}/venue-rules`, { query: { symbol } }));
315
+ }
316
+ /** Poll a decision until it no longer waits for a person, or until timeoutMs. */
317
+ async wait(decisionId, { timeoutMs = 60_000, intervalMs = 1_000 } = {}) {
318
+ const deadline = Date.now() + timeoutMs;
319
+ for (;;) {
320
+ const d = await this.decision(decisionId);
321
+ if (resolved(d) || Date.now() >= deadline)
322
+ return d;
323
+ await new Promise((r) => setTimeout(r, Math.min(intervalMs, Math.max(0, deadline - Date.now()))));
324
+ }
325
+ }
326
+ // Plumbing
327
+ #request(method, p, { body, query, timeoutMs } = {}) {
328
+ const address = this.address;
329
+ if (!address)
330
+ return Promise.reject(new Error("the signer is not started"));
331
+ const params = new URLSearchParams();
332
+ for (const [k, v] of Object.entries(query ?? {}))
333
+ if (v !== undefined)
334
+ params.set(k, String(v));
335
+ const queryText = params.toString();
336
+ const data = body === undefined ? undefined : JSON.stringify(body);
337
+ const headers = { Accept: "application/json" };
338
+ if (data !== undefined)
339
+ headers["Content-Type"] = "application/json";
340
+ if (this.token)
341
+ headers["Authorization"] = "Bearer " + this.token;
342
+ const target = address.startsWith("unix:")
343
+ ? { socketPath: address.slice("unix:".length) }
344
+ : { host: address.slice(0, address.lastIndexOf(":")), port: Number(address.slice(address.lastIndexOf(":") + 1)) };
345
+ return new Promise((resolve, reject) => {
346
+ const req = http.request({ ...target, method, path: p + (queryText ? "?" + queryText : ""), headers, timeout: timeoutMs ?? this.options.timeoutMs ?? 40_000 }, (res) => {
347
+ const chunks = [];
348
+ res.on("data", (c) => chunks.push(c));
349
+ res.on("end", () => {
350
+ const text = Buffer.concat(chunks).toString("utf8");
351
+ if ((res.statusCode ?? 0) >= 400) {
352
+ let code = "http_error";
353
+ let message = res.statusMessage || text.slice(0, 200);
354
+ try {
355
+ const parsed = JSON.parse(text);
356
+ if (typeof parsed.code === "string")
357
+ code = parsed.code;
358
+ if (typeof parsed.message === "string")
359
+ message = parsed.message;
360
+ }
361
+ catch {
362
+ // not JSON: keep the text
363
+ }
364
+ reject(new EngineError(res.statusCode ?? 0, code, message));
365
+ return;
366
+ }
367
+ try {
368
+ resolve(text ? JSON.parse(text) : null);
369
+ }
370
+ catch (err) {
371
+ reject(err);
372
+ }
373
+ });
374
+ });
375
+ req.on("timeout", () => req.destroy(new Error("the signer did not answer in time")));
376
+ req.on("error", reject);
377
+ if (data !== undefined)
378
+ req.write(data);
379
+ req.end();
380
+ });
381
+ }
382
+ }
383
+ function freePort() {
384
+ return new Promise((resolve, reject) => {
385
+ const srv = net.createServer();
386
+ srv.listen(0, "127.0.0.1", () => {
387
+ const port = srv.address().port;
388
+ srv.close(() => resolve(port));
389
+ });
390
+ srv.on("error", reject);
391
+ });
392
+ }
package/dist/time.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ /** Times as the contract writes them. */
2
+ /** Format a time as the contract wants it: UTC, Z suffix, fraction trimmed. */
3
+ export declare function rfc3339(d: Date): string;
4
+ /** A Date from an RFC 3339 string that carries an offset. */
5
+ export declare function parseTime(s: string): Date;
package/dist/time.js ADDED
@@ -0,0 +1,17 @@
1
+ /** Times as the contract writes them. */
2
+ /** Format a time as the contract wants it: UTC, Z suffix, fraction trimmed. */
3
+ export function rfc3339(d) {
4
+ const iso = d.toISOString(); // 2026-10-18T10:00:00.250Z
5
+ const [whole, rest] = iso.split(".");
6
+ const fraction = (rest ?? "").replace(/Z$/, "").replace(/0+$/, "");
7
+ return fraction ? `${whole}.${fraction}Z` : `${whole}Z`;
8
+ }
9
+ /** A Date from an RFC 3339 string that carries an offset. */
10
+ export function parseTime(s) {
11
+ if (!/(Z|[+-]\d\d:\d\d)$/.test(s))
12
+ throw new Error(`time ${JSON.stringify(s)} has no offset`);
13
+ const d = new Date(s);
14
+ if (Number.isNaN(d.getTime()))
15
+ throw new Error(`not a time: ${JSON.stringify(s)}`);
16
+ return d;
17
+ }
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "mishmar",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Mishmar client: sign and submit proposals to the engine that guards your trading agent",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.build.json",
21
+ "prepublishOnly": "npm run build",
22
+ "typecheck": "tsc --noEmit",
23
+ "test": "node --test \"tests/**/*.test.ts\""
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^24.3.0",
27
+ "typescript": "^5.9.3"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/mishmar-io/mishmar.git",
32
+ "directory": "typescript"
33
+ },
34
+ "homepage": "https://mishmar.io",
35
+ "keywords": [
36
+ "trading",
37
+ "agents",
38
+ "risk",
39
+ "guardrails",
40
+ "hyperliquid",
41
+ "binance"
42
+ ]
43
+ }