create-evda 0.1.4 → 0.1.6

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 (57) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +93 -8
  3. package/bin/evda.mjs +437 -5
  4. package/create-evda.mjs +68 -12
  5. package/docs/local-model.md +34 -0
  6. package/hooks/evda-read-guard.mjs +7 -0
  7. package/lib/chat-export.mjs +198 -0
  8. package/lib/circle-plan.mjs +277 -0
  9. package/lib/circles.mjs +479 -28
  10. package/lib/connect.mjs +46 -0
  11. package/lib/day-file.mjs +93 -0
  12. package/lib/declarations.mjs +235 -0
  13. package/lib/devices.mjs +202 -0
  14. package/lib/doctor.mjs +351 -19
  15. package/lib/filechanges.mjs +622 -0
  16. package/lib/folders.mjs +22 -8
  17. package/lib/gather-apply.mjs +635 -0
  18. package/lib/gather.mjs +808 -0
  19. package/lib/grants.mjs +7 -1
  20. package/lib/guard.mjs +132 -0
  21. package/lib/hooks.mjs +124 -0
  22. package/lib/keep-hook.mjs +185 -0
  23. package/lib/kinds.mjs +23 -6
  24. package/lib/kinds.schema.json +2 -1
  25. package/lib/manifest.mjs +14 -1
  26. package/lib/mcp-auth.mjs +370 -0
  27. package/lib/mcp-http.mjs +336 -0
  28. package/lib/mcp.mjs +467 -35
  29. package/lib/migrate-people.mjs +231 -0
  30. package/lib/organize.mjs +52 -12
  31. package/lib/parity.mjs +111 -0
  32. package/lib/presets.mjs +379 -0
  33. package/lib/prompt-hook.mjs +88 -0
  34. package/lib/publish.mjs +264 -0
  35. package/lib/readers.mjs +319 -0
  36. package/lib/records.mjs +52 -0
  37. package/lib/references.mjs +5 -51
  38. package/lib/render.mjs +158 -0
  39. package/lib/roles.mjs +102 -1
  40. package/lib/secret-shape.mjs +58 -0
  41. package/lib/setup.mjs +146 -2
  42. package/lib/skills.mjs +266 -0
  43. package/lib/subrecord.mjs +121 -0
  44. package/lib/sync-rules.mjs +87 -0
  45. package/lib/tools.mjs +120 -29
  46. package/lib/update-check.mjs +146 -0
  47. package/lib/work.mjs +180 -0
  48. package/lib/yaml.mjs +117 -42
  49. package/package.json +25 -15
  50. package/presets/journal/preset.json +19 -0
  51. package/presets/projects-and-areas/preset.json +34 -0
  52. package/skills/evda/SKILL.md +76 -0
  53. package/templates/AGENTS.md +129 -15
  54. package/templates/README.md +15 -1
  55. package/templates/circles-README.md +3 -3
  56. package/templates/evda-digest.md +32 -5
  57. package/LICENSE +0 -21
@@ -0,0 +1,336 @@
1
+ // `evda mcp --http`: the same server as `evda mcp`, over MCP's streamable HTTP transport, so a
2
+ // record can be reached by the Claude apps, which connect from Anthropic's cloud to a public HTTPS
3
+ // address (HQ-1378). The tools, the instructions and the per-session nudge are lib/mcp.mjs's; this
4
+ // file adds the transport, the OAuth gate in front of it (lib/mcp-auth.mjs), a rate limit, and a
5
+ // call log that holds tool names, statuses and durations and never a word of content.
6
+ //
7
+ // It binds to 127.0.0.1 by default and speaks plain HTTP: TLS and the public name belong to the
8
+ // tunnel in front of it (Tailscale Funnel on Hestia). --public-url is the address the world uses,
9
+ // and every URL in the metadata is built from it rather than from a Host header anyone can send.
10
+ //
11
+ // The caller is always Claude. A remote client cannot be a model on the owner's own devices, and its
12
+ // clientInfo is whatever it says, so read_by (HQ-1374) applies as it does to Claude locally.
13
+ //
14
+ // Routes, and nothing else answers:
15
+ // POST|GET|DELETE /mcp the MCP endpoint (bearer token required)
16
+ // GET /.well-known/oauth-protected-resource[/mcp] RFC 9728
17
+ // GET /.well-known/oauth-authorization-server RFC 8414
18
+ // POST /register GET|POST /authorize POST /token POST /revoke
19
+
20
+ import { createServer } from "node:http";
21
+ import { appendFileSync } from "node:fs";
22
+ import { randomBytes } from "node:crypto";
23
+ import { openEvda } from "./manifest.mjs";
24
+ import { handle, newSession, TOOLS } from "./mcp.mjs";
25
+ import { callerFrom } from "./readers.mjs";
26
+ import * as auth from "./mcp-auth.mjs";
27
+
28
+ const TOOL_NAMES = new Set(TOOLS.map((t) => t.name));
29
+ const METHODS = new Set(["initialize", "ping", "tools/list", "tools/call", "resources/list", "resources/read", "resources/templates/list", "prompts/list", "notifications/initialized", "notifications/cancelled"]);
30
+ export const MCP_BODY_LIMIT = 4 * 1024 * 1024;
31
+ const FORM_BODY_LIMIT = 16 * 1024;
32
+ export const SESSION_IDLE_MS = 12 * 60 * 60 * 1000;
33
+ const MAX_SESSIONS = 200;
34
+ const PENDING_TTL_MS = 10 * 60 * 1000;
35
+ export const OWNER_CODE_MAX_FAILURES = 5;
36
+
37
+ // Fixed-window counters. The numbers are generous for one person's connector and small for anyone
38
+ // probing: an MCP session makes a few calls a minute, an authorization makes three requests once.
39
+ export const LIMITS = {
40
+ mcp: { max: 240, windowMs: 60_000 }, // per connection (token family)
41
+ anon: { max: 60, windowMs: 60_000 }, // per address, every unauthenticated request
42
+ owner: { max: 10, windowMs: 15 * 60_000 }, // owner code attempts, per address
43
+ };
44
+
45
+ function limiter() {
46
+ const hits = new Map();
47
+ return (key, { max, windowMs }, now) => {
48
+ const h = hits.get(key);
49
+ if (!h || now - h.start >= windowMs) { hits.set(key, { start: now, n: 1 }); if (hits.size > 5000) hits.clear(); return 0; }
50
+ h.n += 1;
51
+ return h.n > max ? Math.ceil((h.start + windowMs - now) / 1000) : 0;
52
+ };
53
+ }
54
+
55
+ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
56
+ const shortId = (s) => auth.sha256(String(s)).slice(0, 8);
57
+
58
+ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env, log = (line) => process.stderr.write(`${line}\n`), clock = () => Date.now() }) {
59
+ const base = String(publicUrl).replace(/\/+$/, "");
60
+ const baseUrl = new URL(base);
61
+ if (baseUrl.protocol !== "https:" && !["127.0.0.1", "localhost"].includes(baseUrl.hostname)) throw new Error(`--public-url must be https (got ${base})`);
62
+ const resource = `${base}/mcp`;
63
+ const prmUrl = `${base}/.well-known/oauth-protected-resource/mcp`;
64
+ const evda = openEvda(dir); // refuse to start on something that is not a record
65
+ const store = auth.openStore(auth.authDir(evda, env));
66
+ const sessions = new Map();
67
+ const pending = new Map();
68
+ const limit = limiter();
69
+ let ownerFailures = [];
70
+ const readerEnv = { EVDA_READER: "claude" };
71
+ const record = (o) => log(JSON.stringify({ t: new Date(clock()).toISOString(), ...o }));
72
+
73
+ const server = createServer(async (req, res) => {
74
+ const started = clock();
75
+ const url = new URL(req.url, base);
76
+ const path = url.pathname;
77
+ const ip = clientIp(req);
78
+ const note = { who: null };
79
+ const send = (status, body, headers = {}) => {
80
+ const isJson = body !== null && typeof body === "object";
81
+ res.writeHead(status, { "cache-control": "no-store", "x-content-type-options": "nosniff", ...(isJson ? { "content-type": "application/json" } : {}), ...headers });
82
+ res.end(body === null || body === undefined ? undefined : isJson ? JSON.stringify(body) : body);
83
+ // Path only: a query string on /authorize carries a state and a challenge, and on a bad
84
+ // request could carry anything.
85
+ record({ http: req.method, path: knownPath(path), status, ms: clock() - started, ...(note.who ? { conn: note.who } : {}) });
86
+ };
87
+ try {
88
+ const now = clock();
89
+ if (path === "/mcp") return await mcp(req, res, { send, now, ip, note });
90
+ const wait = limit(`anon:${ip}`, LIMITS.anon, now);
91
+ if (wait) return send(429, { error: "rate_limited" }, { "retry-after": String(wait) });
92
+ if (req.method === "GET" && (path === "/.well-known/oauth-protected-resource" || path === "/.well-known/oauth-protected-resource/mcp")) return send(200, auth.protectedResourceMetadata(base, resource));
93
+ if (req.method === "GET" && path === "/.well-known/oauth-authorization-server") return send(200, auth.authServerMetadata(base));
94
+ if (req.method === "POST" && path === "/register") {
95
+ const body = await readJson(req, FORM_BODY_LIMIT);
96
+ if (body === undefined) return send(400, { error: "invalid_client_metadata", error_description: "The body must be JSON." });
97
+ const r = auth.registerClient(store, body, { now });
98
+ return send(r.status, r.body);
99
+ }
100
+ if (path === "/authorize" && req.method === "GET") return authorizePage(url, send, now);
101
+ if (path === "/authorize" && req.method === "POST") return await authorizeSubmit(req, send, now, ip);
102
+ if (req.method === "POST" && path === "/token") {
103
+ const form = await readForm(req);
104
+ if (!form) return send(400, { error: "invalid_request", error_description: "The body must be application/x-www-form-urlencoded." });
105
+ const r = auth.token(store, form, { resource, now, env });
106
+ return send(r.status, r.body);
107
+ }
108
+ if (req.method === "POST" && path === "/revoke") {
109
+ const form = await readForm(req);
110
+ if (!form) return send(400, { error: "invalid_request" });
111
+ const r = auth.revoke(store, form, { now, env });
112
+ return send(r.status, r.body);
113
+ }
114
+ return send(404, { error: "not_found" });
115
+ } catch (e) {
116
+ if (e?.code === "TOO_LARGE") return send(413, { error: "too_large" });
117
+ record({ error: e?.code ?? "internal", path: knownPath(path) });
118
+ log(e?.stack ?? String(e));
119
+ if (!res.headersSent) return send(500, { error: "server_error" });
120
+ }
121
+ });
122
+
123
+ async function mcp(req, res, { send, now, ip, note }) {
124
+ const origin = req.headers.origin;
125
+ if (origin && origin !== baseUrl.origin) return send(403, { error: "forbidden_origin" });
126
+ const m = /^Bearer ([A-Za-z0-9._~+/-]+=*)$/.exec(req.headers.authorization ?? "");
127
+ let grant = null;
128
+ if (auth.FALSIFY("no-token-check", env)) grant = { family: "falsified" };
129
+ else if (m) grant = auth.verifyAccess(store, m[1], { resource, now, env });
130
+ if (!grant) {
131
+ const wait = limit(`anon:${ip}`, LIMITS.anon, now);
132
+ if (wait) return send(429, { error: "rate_limited" }, { "retry-after": String(wait) });
133
+ const challenge = `Bearer resource_metadata="${prmUrl}", scope="${auth.SCOPE}"${m ? ', error="invalid_token"' : ""}`;
134
+ return send(401, { error: m ? "invalid_token" : "unauthorized" }, { "www-authenticate": challenge });
135
+ }
136
+ note.who = shortId(grant.family);
137
+ const wait = limit(`mcp:${grant.family}`, LIMITS.mcp, now);
138
+ if (wait) return send(429, { error: "rate_limited" }, { "retry-after": String(wait) });
139
+ const asked = req.headers["mcp-protocol-version"];
140
+ if (asked && !["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"].includes(asked)) return send(400, { jsonrpc: "2.0", id: null, error: { code: -32600, message: `unsupported MCP-Protocol-Version ${String(asked).slice(0, 20)}` } });
141
+ const sid = req.headers["mcp-session-id"];
142
+ if (req.method === "GET") return send(405, { error: "method_not_allowed" }, { allow: "POST, DELETE" });
143
+ if (req.method === "DELETE") {
144
+ const s = sessions.get(sid);
145
+ if (s && s.family === grant.family) sessions.delete(sid);
146
+ return send(s ? 204 : 404, null);
147
+ }
148
+ if (req.method !== "POST") return send(405, { error: "method_not_allowed" }, { allow: "POST, DELETE" });
149
+ if (!String(req.headers["content-type"] ?? "").includes("application/json")) return send(415, { error: "unsupported_media_type" });
150
+ let msg;
151
+ const raw = await readBody(req, MCP_BODY_LIMIT);
152
+ try { msg = JSON.parse(raw); } catch { return send(400, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }); }
153
+ const batch = Array.isArray(msg) ? msg : [msg];
154
+ const isInit = batch.some((x) => x?.method === "initialize");
155
+ let session;
156
+ let newSid = null;
157
+ if (isInit) {
158
+ if (batch.length !== 1) return send(400, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "initialize must be sent alone" } });
159
+ evictSessions(now);
160
+ newSid = randomBytes(24).toString("hex");
161
+ session = { family: grant.family, last: now, ctx: { version, log: (s) => log(s), env: readerEnv, evda: () => openEvda(dir), session: newSession(), caller: callerFrom({ env: readerEnv }) } };
162
+ sessions.set(newSid, session);
163
+ } else {
164
+ if (!sid) return send(400, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "Mcp-Session-Id header required; initialize first" } });
165
+ session = sessions.get(sid);
166
+ if (!session || session.family !== grant.family) return send(404, { jsonrpc: "2.0", id: null, error: { code: -32001, message: "session not found; initialize again" } });
167
+ session.last = now;
168
+ }
169
+ const answers = [];
170
+ for (const one of batch) {
171
+ const t0 = clock();
172
+ const method = typeof one?.method === "string" ? one.method : null;
173
+ const tool = method === "tools/call" ? one.params?.name : null;
174
+ // The server decides who is calling, whatever clientInfo says.
175
+ const r = await handle(one, session.ctx).catch((e) => { log(e?.stack ?? String(e)); return { jsonrpc: "2.0", id: one?.id ?? null, error: { code: -32603, message: "internal error" } }; });
176
+ session.ctx.caller = callerFrom({ env: readerEnv });
177
+ if (method && one?.id !== undefined && one?.id !== null) {
178
+ const st = r?.error ? "error" : r?.result?.isError ? "refused" : "ok";
179
+ const line = { call: method === "tools/call" ? (TOOL_NAMES.has(tool) ? tool : "unknown_tool") : METHODS.has(method) ? method : "unknown_method", status: st, ms: clock() - t0, session: shortId(sid ?? newSid), conn: note.who };
180
+ if (auth.FALSIFY("log-content", env)) line.args = one.params?.arguments;
181
+ record(line);
182
+ }
183
+ if (r) answers.push(r);
184
+ }
185
+ if (!answers.length) return send(202, null);
186
+ return send(200, Array.isArray(msg) ? answers : answers[0], newSid ? { "mcp-session-id": newSid } : {});
187
+ }
188
+
189
+ function evictSessions(now) {
190
+ for (const [k, s] of sessions) if (now - s.last > SESSION_IDLE_MS) sessions.delete(k);
191
+ while (sessions.size >= MAX_SESSIONS) sessions.delete(sessions.keys().next().value);
192
+ for (const [k, p] of pending) if (p.expires <= now) pending.delete(k);
193
+ }
194
+
195
+ function authorizePage(url, send, now) {
196
+ const q = Object.fromEntries(url.searchParams);
197
+ const v = auth.checkAuthorizeRequest(store, q, { resource, env });
198
+ if (!v.ok) {
199
+ if (v.redirect) return send(302, null, { location: withParams(v.redirect, { error: v.error, error_description: v.description, state: v.state, iss: base }) });
200
+ return send(v.status, page("This connection cannot go ahead", `<p>${esc(v.description)}</p>`), { "content-type": "text/html; charset=utf-8", ...pageHeaders() });
201
+ }
202
+ evictSessions(now);
203
+ const id = randomBytes(24).toString("hex");
204
+ pending.set(id, { ...v.req, expires: now + PENDING_TTL_MS });
205
+ return send(200, consentPage(id, v.req), { "content-type": "text/html; charset=utf-8", ...pageHeaders(v.req.redirect_uri) });
206
+ }
207
+
208
+ async function authorizeSubmit(req, send, now, ip) {
209
+ // The form posts to itself. A browser sends Origin on a POST; anything else is not this page.
210
+ const origin = req.headers.origin;
211
+ if (origin && origin !== baseUrl.origin) return send(403, page("Refused", "<p>That form did not come from this page.</p>"), { "content-type": "text/html; charset=utf-8", ...pageHeaders() });
212
+ const form = await readForm(req);
213
+ const p = form && pending.get(form.request);
214
+ if (!p || p.expires <= now) return send(400, page("This page expired", "<p>Go back to Claude and connect again.</p>"), { "content-type": "text/html; charset=utf-8", ...pageHeaders() });
215
+ const html = { "content-type": "text/html; charset=utf-8", ...pageHeaders(p.redirect_uri) };
216
+ if (form.decision === "deny") {
217
+ pending.delete(form.request);
218
+ return send(302, null, { location: withParams(p.redirect_uri, { error: "access_denied", state: p.state, iss: base }) });
219
+ }
220
+ const wait = limit(`owner:${ip}`, LIMITS.owner, now);
221
+ if (wait) return send(429, page("Too many tries", "<p>Wait a few minutes, then try again.</p>"), { ...html, "retry-after": String(wait) });
222
+ if (!auth.spendOwnerCode(store, form.owner_code, now)) {
223
+ ownerFailures = ownerFailures.filter((t) => now - t < LIMITS.owner.windowMs).concat(now);
224
+ record({ owner_code: "wrong", failures: ownerFailures.length });
225
+ if (ownerFailures.length >= OWNER_CODE_MAX_FAILURES) {
226
+ // Five wrong codes in fifteen minutes: every open owner code is spent. A new one comes from
227
+ // the machine, which is the whole point of having one.
228
+ auth.burnOwnerCodes(store, now);
229
+ ownerFailures = [];
230
+ pending.delete(form.request);
231
+ record({ owner_code: "burned" });
232
+ return send(403, page("Owner codes cancelled", "<p>Too many wrong codes, so every open owner code was cancelled. Ask your agent for a new one.</p>"), html);
233
+ }
234
+ return send(401, consentPage(form.request, p, "That code is not right, or it has already been used."), html);
235
+ }
236
+ pending.delete(form.request);
237
+ const code = auth.issueCode(store, p, { now });
238
+ record({ owner_code: "accepted", client: shortId(p.client_id) });
239
+ return send(302, null, { location: withParams(p.redirect_uri, { code, state: p.state, iss: base }) });
240
+ }
241
+
242
+ function consentPage(id, r, problem = "") {
243
+ const host = (() => { try { return new URL(r.redirect_uri).host; } catch { return "?"; } })();
244
+ const body = `
245
+ <p><b>${esc(r.client_name || "A client")}</b> wants to read and write your EVÐA through this server. When you allow it, you are sent back to <b>${esc(host)}</b>.</p>
246
+ ${problem ? `<p class="bad">${esc(problem)}</p>` : ""}
247
+ <form method="post" action="/authorize">
248
+ <input type="hidden" name="request" value="${esc(id)}">
249
+ <label for="c">Owner code. Ask your agent for one.</label>
250
+ <input id="c" name="owner_code" autocomplete="one-time-code" autocapitalize="characters" spellcheck="false" placeholder="XXXX-XXXX-XXXX-XXXX" required>
251
+ <div class="row"><button name="decision" value="allow" type="submit">Allow</button><button name="decision" value="deny" type="submit" formnovalidate class="quiet">Deny</button></div>
252
+ </form>`;
253
+ return page("Connect to your EVÐA", body);
254
+ }
255
+
256
+ function pageHeaders(redirect) {
257
+ let to = "";
258
+ try { if (redirect) to = ` ${new URL(redirect).origin}`; } catch { /* no redirect */ }
259
+ return { "content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; form-action 'self'${to}; frame-ancestors 'none'; base-uri 'none'`, "x-frame-options": "DENY", "referrer-policy": "same-origin" };
260
+ }
261
+
262
+ return { server, store, sessions, resource, base };
263
+ }
264
+
265
+ function page(title, body) {
266
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title>
267
+ <style>:root{color-scheme:light dark}body{font:16px/1.5 -apple-system,system-ui,sans-serif;max-width:32rem;margin:3rem auto;padding:0 16px}input{font:inherit;width:100%;padding:.6rem;margin:.4rem 0 1rem;box-sizing:border-box;letter-spacing:.08em}button{font:inherit;padding:.6rem 1.2rem;margin-right:.5rem}.quiet{background:none;border:1px solid #888}.bad{color:#c33}code{font-size:.9em}</style>
268
+ </head><body><h1>${esc(title)}</h1>${body}</body></html>`;
269
+ }
270
+
271
+ function withParams(uri, params) {
272
+ const u = new URL(uri);
273
+ for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null) u.searchParams.set(k, v);
274
+ return u.toString();
275
+ }
276
+
277
+ function knownPath(p) {
278
+ return ["/mcp", "/register", "/authorize", "/token", "/revoke", "/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp", "/.well-known/oauth-authorization-server"].includes(p) ? p : "other";
279
+ }
280
+
281
+ // Behind the tunnel every connection comes from loopback, and the tunnel names the real address in
282
+ // X-Forwarded-For. A direct connection from anywhere else is its own address.
283
+ function clientIp(req) {
284
+ const remote = req.socket.remoteAddress ?? "";
285
+ const loop = remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
286
+ const fwd = String(req.headers["x-forwarded-for"] ?? "").split(",")[0].trim();
287
+ return loop && fwd ? fwd : remote;
288
+ }
289
+
290
+ function readBody(req, max) {
291
+ return new Promise((resolveBody, reject) => {
292
+ let n = 0;
293
+ const chunks = [];
294
+ req.on("data", (c) => {
295
+ n += c.length;
296
+ if (n > max) { const e = new Error("too large"); e.code = "TOO_LARGE"; req.destroy(); reject(e); return; }
297
+ chunks.push(c);
298
+ });
299
+ req.on("end", () => resolveBody(Buffer.concat(chunks).toString("utf8")));
300
+ req.on("error", reject);
301
+ });
302
+ }
303
+
304
+ async function readJson(req, max) {
305
+ try { return JSON.parse(await readBody(req, max)); } catch (e) { if (e.code === "TOO_LARGE") throw e; return undefined; }
306
+ }
307
+
308
+ // /token and /revoke take form bodies (RFC 6749); JSON is accepted too, for clients that send it.
309
+ async function readForm(req) {
310
+ const type = String(req.headers["content-type"] ?? "");
311
+ const raw = await readBody(req, FORM_BODY_LIMIT);
312
+ if (type.includes("application/x-www-form-urlencoded")) return Object.fromEntries(new URLSearchParams(raw));
313
+ if (type.includes("application/json")) { try { const j = JSON.parse(raw); return j && typeof j === "object" && !Array.isArray(j) ? j : null; } catch { return null; } }
314
+ return null;
315
+ }
316
+
317
+ // Serve until killed. `lifeline: "ppid"` exits when the process that started this one goes away,
318
+ // which is how a server started through `ssh localhost` by launchd stops when launchd stops the ssh:
319
+ // with no terminal, nothing else would tell it, and an orphan would hold the port.
320
+ export function serveHttp({ dir, version, port, host = "127.0.0.1", publicUrl, logFile, lifeline, env = process.env }) {
321
+ const log = logFile ? (line) => appendFileSync(logFile, `${line}\n`, { mode: 0o600 }) : (line) => process.stderr.write(`${line}\n`);
322
+ const { server, resource } = createMcpHttpServer({ dir, version, publicUrl, env, log });
323
+ return new Promise((resolveDone, reject) => {
324
+ server.on("error", reject);
325
+ server.listen(port, host, () => {
326
+ log(JSON.stringify({ t: new Date().toISOString(), started: true, listen: `${host}:${server.address().port}`, resource }));
327
+ if (lifeline === "ppid") {
328
+ const parent = process.ppid;
329
+ setInterval(() => { if (process.ppid !== parent) { log(JSON.stringify({ t: new Date().toISOString(), stopped: "parent gone" })); process.exit(0); } }, 2000).unref();
330
+ }
331
+ });
332
+ const stop = () => server.close(() => resolveDone());
333
+ process.on("SIGTERM", stop);
334
+ process.on("SIGINT", stop);
335
+ });
336
+ }