@ferrule-io/ok-fine 0.3.7 → 0.3.8

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/README.md CHANGED
@@ -21,7 +21,7 @@ claude mcp add --scope user ok-fine -- npx -y @ferrule-io/ok-fine
21
21
  ```
22
22
 
23
23
  Other harnesses: [Coding agents](https://github.com/ferrule-io/ok-fine/wiki/Coding-Agents#local-setup-stdio). Git
24
- remotes, flags, and several concurrent sessions: [Running locally](https://github.com/ferrule-io/ok-fine/wiki/Running-Locally).
24
+ remotes, flags, and concurrent sessions: [Running locally](https://github.com/ferrule-io/ok-fine/wiki/Running-Locally).
25
25
 
26
26
  ### HTTP server (REST + MCP)
27
27
 
@@ -3,7 +3,7 @@ import { parseArgs } from "node:util";
3
3
  export const USAGE = `Usage: ok-fine [stdio|serve] [options]
4
4
 
5
5
  Commands:
6
- stdio (default) Serve MCP over stdin/stdout for one local client
6
+ stdio (default) Serve MCP over stdin/stdout
7
7
  serve Serve MCP and the REST API over HTTP
8
8
 
9
9
  Options:
package/dist/cli.js CHANGED
@@ -5,7 +5,8 @@ import { pino } from "pino";
5
5
  import { parseCli, USAGE } from "./cli-options.js";
6
6
  import { loadConfig, loadStorageConfig } from "./config.js";
7
7
  import { lockDataDir } from "./data-dir-lock.js";
8
- import { startServer, startStdioServer } from "./server.js";
8
+ import { startLocalHost, startServer } from "./server.js";
9
+ import { startStdioProxy } from "./stdio-proxy.js";
9
10
  import { VERSION } from "./version.js";
10
11
  const message = (err) => (err instanceof Error ? err.message : String(err));
11
12
  /** `git config user.email` in the launch directory; null when unset or git is unavailable. Not `Git`: it forces ok-fine's own email. */
@@ -57,26 +58,23 @@ if (inv.command === "stdio") {
57
58
  }
58
59
  // stdout carries JSON-RPC only; every log line goes to stderr.
59
60
  const log = pino({ level: config.logLevel }, pino.destination({ dest: 2, sync: true }));
60
- try {
61
- const release = await lockDataDir(config.dataDir);
62
- process.once("exit", release);
63
- }
64
- catch (err) {
65
- log.error(message(err));
66
- process.exit(1);
67
- }
68
61
  const identity = await localGitEmail(inv.env);
69
62
  log.info({ dataDir: config.dataDir, identity }, "serving MCP over stdio");
70
- let server;
63
+ let proxy;
71
64
  try {
72
- server = await startStdioServer(config, { log, identity });
65
+ proxy = await startStdioProxy({
66
+ config,
67
+ identity,
68
+ log,
69
+ startHost: () => startLocalHost(config, { log }),
70
+ });
73
71
  }
74
72
  catch (err) {
75
73
  log.error({ err }, "ok-fine failed to start");
76
74
  process.exit(1);
77
75
  }
78
- const shutdown = shutdownOnce(() => server.close(), (err) => log.error({ err }, "shutdown failed"));
79
- void server.done.then(shutdown);
76
+ const shutdown = shutdownOnce(() => proxy.close(), (err) => log.error({ err }, "shutdown failed"));
77
+ void proxy.done.then(shutdown);
80
78
  process.once("SIGINT", shutdown);
81
79
  process.once("SIGTERM", shutdown);
82
80
  }
@@ -99,7 +97,7 @@ else {
99
97
  }
100
98
  let server;
101
99
  try {
102
- server = await startServer(config);
100
+ server = await startServer(config, { hostEndpoint: config.auth.mode === "none" });
103
101
  }
104
102
  catch (err) {
105
103
  console.error("ok-fine failed to start:", err);
@@ -1,43 +1,52 @@
1
1
  import { readFileSync, unlinkSync } from "node:fs";
2
2
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
4
- function errorCode(err) {
5
- return err?.code;
6
- }
7
- function isRunning(pid) {
3
+ import { join, resolve } from "node:path";
4
+ /** Checks if a process with the given PID is alive. */
5
+ export function isProcessAlive(pid) {
8
6
  try {
9
7
  process.kill(pid, 0);
10
8
  return true;
11
9
  }
12
10
  catch (err) {
13
- return errorCode(err) === "EPERM";
11
+ return err?.code === "EPERM";
14
12
  }
15
13
  }
14
+ /** In-process tracking so concurrent sessions in the same process do not clobber each other's locks. */
15
+ const inProcessLocks = new Set();
16
16
  /**
17
- * Takes the exclusive per-data-dir lock for a local CLI process; a lock left by a dead process is taken over.
18
- * Returns a synchronous release, safe to call from `process.on("exit")`.
17
+ * Attempts to acquire the exclusive per-data-dir lock without throwing.
18
+ * Returns a release function (callable directly or via .release()) on success,
19
+ * or { acquired: false, holderPid } when held by another process or concurrent session.
19
20
  */
20
- export async function lockDataDir(dataDir) {
21
+ export async function tryLockDataDir(dataDir) {
22
+ const canonicalDir = resolve(dataDir);
23
+ if (inProcessLocks.has(canonicalDir)) {
24
+ return { acquired: false, holderPid: process.pid };
25
+ }
21
26
  await mkdir(dataDir, { recursive: true });
22
27
  const lockPath = join(dataDir, "ok-fine.lock");
23
28
  const pid = String(process.pid);
24
- let holder = "unknown";
25
29
  for (let attempt = 0; attempt < 2; attempt++) {
26
30
  try {
27
31
  await writeFile(lockPath, `${pid}\n`, { flag: "wx" });
28
- return () => {
32
+ inProcessLocks.add(canonicalDir);
33
+ const releaseFn = (() => {
34
+ inProcessLocks.delete(canonicalDir);
29
35
  try {
30
36
  if (readFileSync(lockPath, "utf8").trim() === pid)
31
37
  unlinkSync(lockPath);
32
38
  }
33
39
  catch (err) {
34
- if (errorCode(err) !== "ENOENT")
40
+ if (err?.code !== "ENOENT")
35
41
  throw err;
36
42
  }
37
- };
43
+ });
44
+ releaseFn.acquired = true;
45
+ releaseFn.release = releaseFn;
46
+ return releaseFn;
38
47
  }
39
48
  catch (err) {
40
- if (errorCode(err) !== "EEXIST")
49
+ if (err?.code !== "EEXIST")
41
50
  throw err;
42
51
  }
43
52
  let content;
@@ -45,16 +54,46 @@ export async function lockDataDir(dataDir) {
45
54
  content = (await readFile(lockPath, "utf8")).trim();
46
55
  }
47
56
  catch (err) {
48
- if (errorCode(err) === "ENOENT")
57
+ if (err?.code === "ENOENT")
49
58
  continue;
50
59
  throw err;
51
60
  }
52
- holder = content || "unknown";
53
61
  const holderPid = Number.parseInt(content, 10);
54
- if (Number.isInteger(holderPid) && holderPid > 0 && holderPid !== process.pid && isRunning(holderPid))
55
- break;
62
+ if (Number.isInteger(holderPid) && holderPid > 0) {
63
+ if (holderPid !== process.pid && isProcessAlive(holderPid)) {
64
+ return { acquired: false, holderPid };
65
+ }
66
+ if (holderPid === process.pid && inProcessLocks.has(canonicalDir)) {
67
+ return { acquired: false, holderPid: process.pid };
68
+ }
69
+ }
56
70
  await rm(lockPath, { force: true });
57
71
  }
58
- throw new Error(`ok-fine is already running on ${dataDir} (pid ${holder}). One process serves a data directory: connect more clients to \`ok-fine serve\` over HTTP, or pass a different --data-dir. If no ok-fine process is running, delete ${lockPath}.`);
72
+ let content = null;
73
+ try {
74
+ content = (await readFile(lockPath, "utf8")).trim();
75
+ }
76
+ catch {
77
+ // ignore
78
+ }
79
+ const finalHolder = content ? Number.parseInt(content, 10) : null;
80
+ return {
81
+ acquired: false,
82
+ holderPid: finalHolder !== null && Number.isInteger(finalHolder) && finalHolder > 0 ? finalHolder : null,
83
+ };
84
+ }
85
+ /**
86
+ * Takes the exclusive per-data-dir lock for a local CLI process; a lock left by a dead process is taken over.
87
+ * Returns a synchronous release, safe to call from `process.on("exit")`.
88
+ * Throws if already held by a live process or concurrent session.
89
+ */
90
+ export async function lockDataDir(dataDir) {
91
+ const result = await tryLockDataDir(dataDir);
92
+ if (result.acquired) {
93
+ return result;
94
+ }
95
+ const lockPath = join(dataDir, "ok-fine.lock");
96
+ const holder = result.holderPid ?? "unknown";
97
+ throw new Error(`ok-fine is already running on ${dataDir} (pid ${holder}). Close existing ok-fine sessions or pass a different --data-dir. If no ok-fine process is running, delete ${lockPath}.`);
59
98
  }
60
99
  //# sourceMappingURL=data-dir-lock.js.map
@@ -0,0 +1,157 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmod, rm } from "node:fs/promises";
3
+ import net from "node:net";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { PassThrough } from "node:stream";
7
+ import { StdioServerTransport, serveStdio } from "@modelcontextprotocol/server/stdio";
8
+ import { z } from "zod";
9
+ import { createMcpServer } from "./mcp/server.js";
10
+ const HelloMessageSchema = z.object({
11
+ okFine: z.literal(1),
12
+ identity: z.string().nullable(),
13
+ });
14
+ /**
15
+ * Resolves the local IPC endpoint path for a given data directory:
16
+ * - Windows: named pipe `\\.\pipe\ok-fine-<first 16 hex of sha256(resolved dataDir)>`
17
+ * - Unix: `DATA_DIR/ok-fine.sock` (or `os.tmpdir()/ok-fine-<hash>.sock` if default exceeds 100 bytes)
18
+ */
19
+ export function localEndpointPath(dataDir) {
20
+ const resolvedDir = resolve(dataDir);
21
+ const hash = createHash("sha256").update(resolvedDir).digest("hex").slice(0, 16);
22
+ if (process.platform === "win32") {
23
+ return `\\\\.\\pipe\\ok-fine-${hash}`;
24
+ }
25
+ const defaultPath = join(resolvedDir, "ok-fine.sock");
26
+ if (Buffer.byteLength(defaultPath) > 100) {
27
+ return join(tmpdir(), `ok-fine-${hash}.sock`);
28
+ }
29
+ return defaultPath;
30
+ }
31
+ /**
32
+ * Starts the local endpoint listener on Unix domain socket or Windows named pipe.
33
+ * Each incoming connection sends a newline-delimited JSON hello with its identity,
34
+ * and receives its own MCP server instance with full local permissions.
35
+ */
36
+ export async function createLocalHost(options) {
37
+ const { service, config, log } = options;
38
+ const endpointPath = localEndpointPath(config.dataDir);
39
+ if (process.platform !== "win32") {
40
+ try {
41
+ await rm(endpointPath, { force: true });
42
+ }
43
+ catch {
44
+ // safe: we hold the data dir lock
45
+ }
46
+ }
47
+ const connections = new Set();
48
+ const server = net.createServer((socket) => {
49
+ socket.on("error", (err) => log.debug({ err }, "local host client socket error"));
50
+ const connRecord = { socket };
51
+ connections.add(connRecord);
52
+ const cleanup = () => {
53
+ connections.delete(connRecord);
54
+ };
55
+ socket.once("close", cleanup);
56
+ socket.once("end", cleanup);
57
+ let buffer = "";
58
+ const onData = (chunk) => {
59
+ buffer += chunk.toString("utf8");
60
+ const idx = buffer.indexOf("\n");
61
+ if (idx === -1) {
62
+ if (buffer.length > 65536) {
63
+ socket.destroy();
64
+ }
65
+ return;
66
+ }
67
+ socket.off("data", onData);
68
+ const firstLine = buffer.slice(0, idx).trim();
69
+ const remainder = buffer.slice(idx + 1);
70
+ let helloRaw;
71
+ try {
72
+ helloRaw = JSON.parse(firstLine);
73
+ }
74
+ catch {
75
+ socket.destroy();
76
+ return;
77
+ }
78
+ const parsed = HelloMessageSchema.safeParse(helloRaw);
79
+ if (!parsed.success) {
80
+ socket.destroy();
81
+ return;
82
+ }
83
+ const identity = parsed.data.identity;
84
+ const principal = {
85
+ subject: "local",
86
+ clientId: "stdio",
87
+ identity,
88
+ scopes: [],
89
+ canRead: true,
90
+ canWrite: true,
91
+ canAdmin: true,
92
+ };
93
+ const inStream = new PassThrough();
94
+ inStream.on("error", (err) => log.debug({ err }, "client inStream error"));
95
+ if (remainder.length > 0) {
96
+ inStream.write(Buffer.from(remainder, "utf8"));
97
+ }
98
+ socket.pipe(inStream);
99
+ const handle = serveStdio(() => createMcpServer(service, principal, log), {
100
+ transport: new StdioServerTransport(inStream, socket),
101
+ onerror: (err) => log.error({ err }, "local host stdio transport error"),
102
+ });
103
+ connRecord.handle = handle;
104
+ };
105
+ socket.on("data", onData);
106
+ });
107
+ const { promise: listenPromise, resolve: resolveListen, reject: rejectListen } = Promise.withResolvers();
108
+ server.once("error", rejectListen);
109
+ server.listen(endpointPath, () => {
110
+ server.off("error", rejectListen);
111
+ resolveListen();
112
+ });
113
+ await listenPromise;
114
+ if (process.platform !== "win32") {
115
+ try {
116
+ await chmod(endpointPath, 0o600);
117
+ }
118
+ catch {
119
+ // safe ignore
120
+ }
121
+ }
122
+ let closing;
123
+ return {
124
+ endpointPath,
125
+ close: () => {
126
+ closing ??= (async () => {
127
+ const { promise: closePromise, resolve: resolveClose } = Promise.withResolvers();
128
+ try {
129
+ server.close(() => resolveClose());
130
+ }
131
+ catch {
132
+ resolveClose();
133
+ }
134
+ const closePromises = [];
135
+ for (const conn of connections) {
136
+ if (conn.handle) {
137
+ closePromises.push(conn.handle.close().catch(() => { }));
138
+ }
139
+ conn.socket.destroy();
140
+ }
141
+ connections.clear();
142
+ await Promise.all(closePromises);
143
+ await closePromise;
144
+ if (process.platform !== "win32") {
145
+ try {
146
+ await rm(endpointPath, { force: true });
147
+ }
148
+ catch {
149
+ // ignore
150
+ }
151
+ }
152
+ })();
153
+ return closing;
154
+ },
155
+ };
156
+ }
157
+ //# sourceMappingURL=local-host.js.map
@@ -3,9 +3,9 @@ import { z } from "zod";
3
3
  import { OkfError } from "../errors.js";
4
4
  import { FEEDBACK_TYPES, feedbackLink } from "../service/feedback.js";
5
5
  import { VERSION } from "../version.js";
6
- export const INSTRUCTIONS = `ok-fine holds shared project knowledge outside the codebase, as OKF v0.2 markdown concepts grouped into projects. In a git repository, first run \`git remote get-url origin\` and call list_projects with that URL as \`repository\`; use the returned project(s) for every read and write. If none match, say the repository is not onboarded and offer to onboard it (ok-fine-onboard skill). Search before non-trivial work; record durable decisions, conventions, and runbooks afterwards.
6
+ export const INSTRUCTIONS = `ok-fine holds shared project knowledge outside the codebase, as OKF v0.2 markdown concepts grouped into projects. In a git repository, first run \`git remote get-url origin\` and call list_projects with that URL as \`repository\`; use the returned project(s) for every read and write. If none match, say the repository is not onboarded and offer to onboard it (ok-fine-onboard skill). Search before planning or editing; record durable decisions, conventions, and runbooks afterwards.
7
7
  1. Discover: get_index (progressive disclosure) or search_concepts with \`project\`.
8
- 2. Read: read_concept returns frontmatter, body, trust tier (unverified | machine-confirmed | human-reviewed), staleness, and links. Freshness comes first: a concept that is stale, or whose code sources changed since \`sources[].commit\`, is a lead to re-check whatever its tier; among fresh concepts prefer higher trust tiers. Deprecated concepts are history; when code contradicts a concept, trust the code and update the concept.
8
+ 2. Read: read_concept returns frontmatter, body, trust tier (unverified | machine-confirmed | human-reviewed), staleness, and links. Freshness comes first: a concept that is stale, when its code sources changed since \`sources[].commit\`, or when that commit is not an ancestor of HEAD (unmerged or rebased away), is a lead to re-check whatever its tier; among fresh concepts prefer higher trust tiers. Knowledge about unmerged work goes into \`proposals/<slug>\` concepts with a \`proposal: { ref: <URI> }\` frontmatter key, never into current-state concepts; concepts carrying \`proposal\` are not current truth. Deprecated concepts are history; when code contradicts a concept, trust the code and update the concept.
9
9
  3. Write: write_concept with frontmatter containing \`type\` (e.g. Decision, Convention, Architecture, Component, Playbook, Interface, Reference) plus \`title\`, \`description\`, \`tags\`, and \`stale_after\` (ISO 8601, e.g. 180 days ahead). Record provenance in \`sources\` (each with \`resource\` and a stable \`id\`; code sources carry \`commit\`) and cite claims with footnotes [^id]. Link concepts with bundle-absolute links such as [orders](/tables/orders.md). After re-checking a concept against the code, refresh \`sources[].commit\` and \`stale_after\` with write_concept, then call verify_concept.
10
10
  4. Pass \`actor\` as <harness>/<model> (e.g. claude-code/claude-opus-4-5, codex/gpt-5-codex, gemini-cli/gemini-2.5-pro). Use human:<email>, with the email from \`git config user.email\`, only when the user personally reviewed the concept; on forbidden_actor, report both identities instead of retrying as another. The server stamps \`generated\`; \`verified\` changes only through verify_concept.
11
11
  5. When updating, pass expectedRevision from read_concept (null to create only).
package/dist/server.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { setTimeout as sleep } from "node:timers/promises";
2
2
  import { buildOAuthProtectedResourceMetadata, createMcpHandler, getOAuthProtectedResourceMetadataUrl, } from "@modelcontextprotocol/server";
3
- import { StdioServerTransport, serveStdio } from "@modelcontextprotocol/server/stdio";
4
3
  import Fastify, {} from "fastify";
5
4
  import { ZodError } from "zod";
6
5
  import { discoverAuthorizationServer } from "./auth/discovery.js";
@@ -10,6 +9,7 @@ import { OkfError } from "./errors.js";
10
9
  import { registerMcpRoute } from "./http/mcp-route.js";
11
10
  import { registerRestRoutes } from "./http/rest.js";
12
11
  import { DEFAULT_UI_DIR, endpointOrigins, registerUiRoutes } from "./http/ui.js";
12
+ import { createLocalHost } from "./local-host.js";
13
13
  import { createMcpServer } from "./mcp/server.js";
14
14
  import { KnowledgeService } from "./service/knowledge-service.js";
15
15
  import { Catalog } from "./store/catalog.js";
@@ -42,6 +42,9 @@ function parseHostHeader(hostHeader) {
42
42
  return trimmed.toLowerCase();
43
43
  }
44
44
  export async function startServer(config, options = {}) {
45
+ if (options.hostEndpoint && config.auth.mode !== "none") {
46
+ throw new Error("Cannot enable hostEndpoint with auth mode oidc");
47
+ }
45
48
  const tp = config.trustProxy;
46
49
  const trustProxy = typeof tp === "number"
47
50
  ? (_addr, hop) => hop < tp
@@ -262,13 +265,28 @@ export async function startServer(config, options = {}) {
262
265
  registerMcpRoute(secured, (request, options) => mcpHandler.fetch(request, options), config.publicBaseUrl);
263
266
  registerRestRoutes(secured, service);
264
267
  });
265
- const address = await app.listen({ port: config.port, host: config.host });
266
268
  const stopSync = startPeriodicSync(config, service, app.log);
269
+ let localHost;
270
+ if (options.hostEndpoint) {
271
+ localHost = await createLocalHost({ service, config, log: app.log });
272
+ }
273
+ let address;
274
+ try {
275
+ address = await app.listen({ port: config.port, host: config.host });
276
+ }
277
+ catch (err) {
278
+ stopSync();
279
+ await localHost?.close();
280
+ await storage.close();
281
+ await mcpHandler.close();
282
+ throw err;
283
+ }
267
284
  return {
268
285
  app,
269
286
  url: address,
270
287
  close: async () => {
271
288
  stopSync();
289
+ await localHost?.close();
272
290
  await app.close();
273
291
  await storage.close();
274
292
  await mcpHandler.close();
@@ -299,40 +317,22 @@ function startPeriodicSync(config, service, log) {
299
317
  clearTimeout(syncTimer);
300
318
  };
301
319
  }
302
- /** Serves MCP over stdio to one local client with full permissions. Only the transport writes to stdout. */
303
- export async function startStdioServer(config, options) {
320
+ /** Starts the local endpoint host that listens on a domain socket / named pipe. */
321
+ export async function startLocalHost(config, options) {
304
322
  const { log } = options;
305
- const stdin = options.stdin ?? process.stdin;
306
- const stdout = options.stdout ?? process.stdout;
307
323
  const storage = await GitBackend.open(config, log);
308
324
  const catalog = new Catalog();
309
325
  const service = new KnowledgeService({ config, storage, catalog, log });
310
326
  await service.initialize();
311
- const principal = {
312
- subject: "local",
313
- clientId: "stdio",
314
- identity: options.identity,
315
- scopes: [],
316
- canRead: true,
317
- canWrite: true,
318
- canAdmin: true,
319
- };
320
327
  const stopSync = startPeriodicSync(config, service, log);
321
- // serveStdio owns transport.onclose, so watch stdin directly.
322
- const { promise: done, resolve } = Promise.withResolvers();
323
- stdin.once("end", resolve);
324
- stdin.once("close", resolve);
325
- const handle = serveStdio(() => createMcpServer(service, principal, log), {
326
- transport: new StdioServerTransport(stdin, stdout),
327
- onerror: (err) => log.error({ err }, "stdio transport error"),
328
- });
328
+ const host = await createLocalHost({ service, config, log });
329
329
  let closing;
330
330
  return {
331
- done,
331
+ endpointPath: host.endpointPath,
332
332
  close: () => {
333
333
  closing ??= (async () => {
334
334
  stopSync();
335
- await handle.close();
335
+ await host.close();
336
336
  // Waits for the storage mutex, so an in-flight write still commits.
337
337
  await storage.close();
338
338
  })();
@@ -0,0 +1,349 @@
1
+ import net from "node:net";
2
+ import { setTimeout as sleep } from "node:timers/promises";
3
+ import { z } from "zod";
4
+ import { isProcessAlive, tryLockDataDir } from "./data-dir-lock.js";
5
+ import { localEndpointPath } from "./local-host.js";
6
+ const JsonRpcMessageSchema = z
7
+ .object({
8
+ jsonrpc: z.string().optional(),
9
+ id: z.union([z.string(), z.number()]).optional(),
10
+ method: z.string().optional(),
11
+ params: z.unknown().optional(),
12
+ result: z.unknown().optional(),
13
+ error: z.unknown().optional(),
14
+ })
15
+ .passthrough();
16
+ function tryConnectSocket(endpoint) {
17
+ const { promise, resolve } = Promise.withResolvers();
18
+ const socket = net.createConnection(endpoint);
19
+ const onError = () => {
20
+ socket.destroy();
21
+ resolve(null);
22
+ };
23
+ socket.once("error", onError);
24
+ socket.once("connect", () => {
25
+ socket.off("error", onError);
26
+ resolve(socket);
27
+ });
28
+ return promise;
29
+ }
30
+ async function connectSocket(endpoint, timeoutMs) {
31
+ const start = Date.now();
32
+ while (Date.now() - start < timeoutMs) {
33
+ const socket = await tryConnectSocket(endpoint);
34
+ if (socket)
35
+ return socket;
36
+ await sleep(25);
37
+ }
38
+ throw new Error(`Failed to connect to local endpoint at ${endpoint}`);
39
+ }
40
+ /**
41
+ * Starts a stdio proxy session. Every stdio CLI process is a proxy:
42
+ * runs election to start the local endpoint host if it acquires the lock,
43
+ * or attaches to an existing host.
44
+ */
45
+ export async function startStdioProxy(options) {
46
+ const { config, identity, log, startHost } = options;
47
+ const stdin = options.stdin ?? process.stdin;
48
+ const stdout = options.stdout ?? process.stdout;
49
+ const timeoutMs = options.electionTimeoutMs ?? 30_000;
50
+ const endpoint = localEndpointPath(config.dataDir);
51
+ const { promise: done, resolve: resolveDone } = Promise.withResolvers();
52
+ let shuttingDown = false;
53
+ let isFailingOver = false;
54
+ let isReconnecting = false;
55
+ let activeSocket = null;
56
+ let activeHost = null;
57
+ let activeLockRelease = null;
58
+ /** Drops the current socket and, if this process hosts, closes the host and releases the lock. */
59
+ async function teardownActive() {
60
+ const socket = activeSocket;
61
+ activeSocket = null;
62
+ if (socket) {
63
+ socket.removeAllListeners();
64
+ socket.destroy();
65
+ }
66
+ const host = activeHost;
67
+ activeHost = null;
68
+ if (host)
69
+ await host.close().catch((err) => log.error({ err }, "closing local host failed"));
70
+ const release = activeLockRelease;
71
+ activeLockRelease = null;
72
+ release?.();
73
+ }
74
+ let storedInitialize = null;
75
+ let storedInitialized = null;
76
+ const inFlightRequests = new Map();
77
+ const pendingStdinLines = [];
78
+ let replaySeq = 0;
79
+ let awaitingReplayId = null;
80
+ let onReplayResponse = null;
81
+ async function runElection(deadline) {
82
+ while (Date.now() < deadline && !shuttingDown) {
83
+ const lockRes = await tryLockDataDir(config.dataDir);
84
+ if (lockRes.acquired) {
85
+ activeLockRelease = lockRes.release;
86
+ try {
87
+ activeHost = await startHost();
88
+ const remainingMs = Math.min(5000, Math.max(100, deadline - Date.now()));
89
+ return await connectSocket(endpoint, remainingMs);
90
+ }
91
+ catch (err) {
92
+ if (activeHost) {
93
+ await activeHost.close().catch(() => { });
94
+ activeHost = null;
95
+ }
96
+ activeLockRelease();
97
+ activeLockRelease = null;
98
+ throw err;
99
+ }
100
+ }
101
+ const holderPid = lockRes.holderPid;
102
+ if (holderPid !== null && isProcessAlive(holderPid)) {
103
+ const socket = await tryConnectSocket(endpoint);
104
+ if (socket)
105
+ return socket;
106
+ }
107
+ await sleep(50);
108
+ }
109
+ const timeoutStr = timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}s` : `${timeoutMs}ms`;
110
+ throw new Error(`Timed out waiting for ok-fine host on ${config.dataDir} after ${timeoutStr}`);
111
+ }
112
+ function handleStdinLine(line) {
113
+ let msg = null;
114
+ try {
115
+ const parsed = JsonRpcMessageSchema.safeParse(JSON.parse(line));
116
+ if (parsed.success)
117
+ msg = parsed.data;
118
+ }
119
+ catch {
120
+ // Non-JSON line, forward as-is
121
+ }
122
+ if (msg) {
123
+ if (msg.method === "initialize" && msg.id !== undefined) {
124
+ storedInitialize = { raw: line, parsed: msg };
125
+ }
126
+ else if (msg.method === "notifications/initialized") {
127
+ storedInitialized = line;
128
+ }
129
+ if (msg.id !== undefined && msg.method !== undefined) {
130
+ inFlightRequests.set(msg.id, msg.id);
131
+ }
132
+ }
133
+ if (isReconnecting || !activeSocket || activeSocket.destroyed || !activeSocket.writable) {
134
+ pendingStdinLines.push(line);
135
+ }
136
+ else {
137
+ activeSocket.write(`${line}\n`);
138
+ }
139
+ }
140
+ let stdinBuffer = "";
141
+ function onStdinData(chunk) {
142
+ stdinBuffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
143
+ while (true) {
144
+ const idx = stdinBuffer.indexOf("\n");
145
+ if (idx === -1)
146
+ break;
147
+ const line = stdinBuffer.slice(0, idx).replace(/\r$/, "");
148
+ stdinBuffer = stdinBuffer.slice(idx + 1);
149
+ if (line.length > 0) {
150
+ handleStdinLine(line);
151
+ }
152
+ }
153
+ }
154
+ function handleSocketLine(line) {
155
+ let msg = null;
156
+ try {
157
+ const parsed = JsonRpcMessageSchema.safeParse(JSON.parse(line));
158
+ if (parsed.success)
159
+ msg = parsed.data;
160
+ }
161
+ catch {
162
+ // Non-JSON line
163
+ }
164
+ if (msg && awaitingReplayId !== null && msg.id === awaitingReplayId) {
165
+ const resolve = onReplayResponse;
166
+ awaitingReplayId = null;
167
+ onReplayResponse = null;
168
+ resolve?.();
169
+ return;
170
+ }
171
+ if (msg && msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
172
+ inFlightRequests.delete(msg.id);
173
+ }
174
+ stdout.write(`${line}\n`);
175
+ }
176
+ let socketBuffer = "";
177
+ function onSocketData(chunk) {
178
+ socketBuffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
179
+ while (true) {
180
+ const idx = socketBuffer.indexOf("\n");
181
+ if (idx === -1)
182
+ break;
183
+ const line = socketBuffer.slice(0, idx).replace(/\r$/, "");
184
+ socketBuffer = socketBuffer.slice(idx + 1);
185
+ if (line.length > 0) {
186
+ handleSocketLine(line);
187
+ }
188
+ }
189
+ }
190
+ async function handleSocketClose() {
191
+ if (shuttingDown || isFailingOver)
192
+ return;
193
+ isFailingOver = true;
194
+ isReconnecting = true;
195
+ try {
196
+ await teardownActive();
197
+ // Answer every in-flight request with host restart error
198
+ for (const id of inFlightRequests.values()) {
199
+ const errorResponse = {
200
+ jsonrpc: "2.0",
201
+ id,
202
+ error: {
203
+ code: -32603,
204
+ message: "ok-fine host restarted; retry the request",
205
+ },
206
+ };
207
+ stdout.write(`${JSON.stringify(errorResponse)}\n`);
208
+ }
209
+ inFlightRequests.clear();
210
+ const deadline = Date.now() + timeoutMs;
211
+ let connectedSocket = null;
212
+ while (Date.now() < deadline && !shuttingDown) {
213
+ try {
214
+ const socket = await runElection(deadline);
215
+ attachSocket(socket);
216
+ socket.write(`${JSON.stringify({ okFine: 1, identity })}\n`);
217
+ if (storedInitialize) {
218
+ const replayId = `okf-replay-${++replaySeq}`;
219
+ awaitingReplayId = replayId;
220
+ const remainingMs = Math.max(1, deadline - Date.now());
221
+ const replayTimeoutMs = Math.min(timeoutMs, remainingMs);
222
+ const { promise: replayPromise, resolve: resolveReplay, reject: rejectReplay, } = Promise.withResolvers();
223
+ onReplayResponse = resolveReplay;
224
+ const onEarlyClose = () => {
225
+ rejectReplay(new Error("Socket closed while awaiting initialize replay response"));
226
+ };
227
+ const onEarlyError = (err) => {
228
+ rejectReplay(err);
229
+ };
230
+ const timer = setTimeout(() => {
231
+ const timeoutStr = replayTimeoutMs % 1000 === 0 ? `${replayTimeoutMs / 1000}s` : `${replayTimeoutMs}ms`;
232
+ rejectReplay(new Error(`Timed out awaiting initialize replay response after ${timeoutStr}`));
233
+ }, replayTimeoutMs);
234
+ socket.once("close", onEarlyClose);
235
+ socket.once("error", onEarlyError);
236
+ try {
237
+ const replayReq = { ...storedInitialize.parsed, id: replayId };
238
+ socket.write(`${JSON.stringify(replayReq)}\n`);
239
+ await replayPromise;
240
+ if (storedInitialized) {
241
+ socket.write(`${storedInitialized}\n`);
242
+ }
243
+ else {
244
+ socket.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`);
245
+ }
246
+ }
247
+ finally {
248
+ clearTimeout(timer);
249
+ socket.off("close", onEarlyClose);
250
+ socket.off("error", onEarlyError);
251
+ if (awaitingReplayId === replayId) {
252
+ awaitingReplayId = null;
253
+ onReplayResponse = null;
254
+ }
255
+ }
256
+ }
257
+ connectedSocket = socket;
258
+ break;
259
+ }
260
+ catch (attemptErr) {
261
+ log.debug({ err: attemptErr }, "failover attempt failed, retrying");
262
+ await teardownActive();
263
+ if (Date.now() >= deadline || shuttingDown) {
264
+ throw attemptErr;
265
+ }
266
+ await sleep(50);
267
+ }
268
+ }
269
+ if (!connectedSocket && !shuttingDown) {
270
+ const timeoutStr = timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}s` : `${timeoutMs}ms`;
271
+ throw new Error(`Timed out waiting for ok-fine host on ${config.dataDir} after ${timeoutStr}`);
272
+ }
273
+ isReconnecting = false;
274
+ const queued = pendingStdinLines.splice(0, pendingStdinLines.length);
275
+ for (const q of queued) {
276
+ connectedSocket?.write(`${q}\n`);
277
+ }
278
+ }
279
+ catch (err) {
280
+ log.error({ err }, "proxy failover failed");
281
+ await shutdown();
282
+ }
283
+ finally {
284
+ isFailingOver = false;
285
+ }
286
+ }
287
+ function attachSocket(socket) {
288
+ activeSocket = socket;
289
+ socketBuffer = "";
290
+ socket.on("data", onSocketData);
291
+ socket.on("error", (err) => log.debug({ err }, "proxy socket error"));
292
+ socket.once("close", () => {
293
+ void handleSocketClose();
294
+ });
295
+ }
296
+ async function shutdown() {
297
+ if (shuttingDown)
298
+ return;
299
+ shuttingDown = true;
300
+ stdin.off("data", onStdinData);
301
+ if (activeSocket) {
302
+ activeSocket.removeAllListeners();
303
+ activeSocket.destroy();
304
+ activeSocket = null;
305
+ }
306
+ if (activeHost) {
307
+ try {
308
+ await activeHost.close();
309
+ }
310
+ catch (err) {
311
+ log.error({ err }, "error closing host on shutdown");
312
+ }
313
+ activeHost = null;
314
+ }
315
+ if (activeLockRelease) {
316
+ try {
317
+ activeLockRelease();
318
+ }
319
+ catch (err) {
320
+ log.error({ err }, "error releasing lock on shutdown");
321
+ }
322
+ activeLockRelease = null;
323
+ }
324
+ resolveDone();
325
+ }
326
+ // Initial election and connection
327
+ const initialSocket = await runElection(Date.now() + timeoutMs);
328
+ attachSocket(initialSocket);
329
+ initialSocket.write(`${JSON.stringify({ okFine: 1, identity })}\n`);
330
+ stdin.on("data", onStdinData);
331
+ stdin.once("end", () => {
332
+ void shutdown();
333
+ });
334
+ stdin.once("close", () => {
335
+ void shutdown();
336
+ });
337
+ if (stdin.readableEnded || stdin.destroyed) {
338
+ void shutdown();
339
+ }
340
+ let closing;
341
+ return {
342
+ done,
343
+ close: () => {
344
+ closing ??= shutdown();
345
+ return closing;
346
+ },
347
+ };
348
+ }
349
+ //# sourceMappingURL=stdio-proxy.js.map
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.3.7";
1
+ export const VERSION = "0.3.8";
2
2
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferrule-io/ok-fine",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "description": "OKF v0.2 knowledge repository for AI agents (MCP + REST)",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {