@gamaze/hicortex 0.23.0 → 0.23.1

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/llm.d.ts CHANGED
@@ -237,9 +237,20 @@ export declare class LlmClient {
237
237
  private acquireFlightGuard;
238
238
  private dispatchOnce;
239
239
  /**
240
- * Claude CLI: shell out to `claude -p` for subscription users.
240
+ * Claude CLI: invoke `claude -p` for subscription users.
241
241
  * No API key needed — uses CC's authenticated session.
242
242
  *
243
+ * Invocation contract (#512): the binary is called with a plain argument
244
+ * array and NO shell; the prompt travels on the child's stdin (execFileSync
245
+ * pipes stdin when `input` is set — the `< /dev/null` of the old shell
246
+ * command line is gone with the shell). The prompt is transcript-derived
247
+ * data, so it must reach the binary as bytes, never as command-line text
248
+ * an intermediary could interpret; argv stays exactly the fixed flag set.
249
+ * Side effects: stdin delivery lifts the per-argument exec limit on long
250
+ * transcripts, and a claudePath containing spaces works (one argv element,
251
+ * never re-parsed). cwd is left unset on purpose — the child inherits
252
+ * process.cwd() like every other phase of the daemon.
253
+ *
243
254
  * Token usage (#246): the claude CLI JSON output does not carry a token
244
255
  * usage field, so this path returns `usage: undefined`. The CLI is billed
245
256
  * by Claude subscription, not per-token — there is nothing to meter. The
package/dist/llm.js CHANGED
@@ -570,9 +570,20 @@ class LlmClient {
570
570
  return this.completeOpenAiCompat(model, prompt, maxTokens, timeoutMs);
571
571
  }
572
572
  /**
573
- * Claude CLI: shell out to `claude -p` for subscription users.
573
+ * Claude CLI: invoke `claude -p` for subscription users.
574
574
  * No API key needed — uses CC's authenticated session.
575
575
  *
576
+ * Invocation contract (#512): the binary is called with a plain argument
577
+ * array and NO shell; the prompt travels on the child's stdin (execFileSync
578
+ * pipes stdin when `input` is set — the `< /dev/null` of the old shell
579
+ * command line is gone with the shell). The prompt is transcript-derived
580
+ * data, so it must reach the binary as bytes, never as command-line text
581
+ * an intermediary could interpret; argv stays exactly the fixed flag set.
582
+ * Side effects: stdin delivery lifts the per-argument exec limit on long
583
+ * transcripts, and a claudePath containing spaces works (one argv element,
584
+ * never re-parsed). cwd is left unset on purpose — the child inherits
585
+ * process.cwd() like every other phase of the daemon.
586
+ *
576
587
  * Token usage (#246): the claude CLI JSON output does not carry a token
577
588
  * usage field, so this path returns `usage: undefined`. The CLI is billed
578
589
  * by Claude subscription, not per-token — there is nothing to meter. The
@@ -580,10 +591,10 @@ class LlmClient {
580
591
  * correct outcome (no meterable cost to defend against).
581
592
  */
582
593
  async completeClaude(model, prompt, timeoutMs) {
583
- const { execSync } = require("node:child_process");
594
+ const { execFileSync } = require("node:child_process");
584
595
  const claudePath = this.config.baseUrl; // baseUrl stores the claude binary path
585
596
  try {
586
- const raw = execSync(`${claudePath} -p ${JSON.stringify(prompt)} --model ${model} --max-turns 1 --output-format json --no-session-persistence < /dev/null`, { encoding: "utf-8", timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 });
597
+ const raw = execFileSync(claudePath, ["-p", "--model", model, "--max-turns", "1", "--output-format", "json", "--no-session-persistence"], { encoding: "utf-8", timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024, input: prompt });
587
598
  const data = JSON.parse(raw);
588
599
  if (data.is_error) {
589
600
  throw new Error(`Claude CLI error: ${data.result}`);
@@ -42,6 +42,27 @@
42
42
  * port: explicit error, never a spawn. A remote target that is down is
43
43
  * likewise an explicit error — we never spawn for remote URLs.
44
44
  *
45
+ * Startup retry (#501): a REMOTE target that is unreachable at launch is
46
+ * TRANSIENT, not fatal — the product case is a client (e.g. Claude Desktop
47
+ * auto-launched at login) starting before the VPN/DNS that carries the
48
+ * server URL is up (ENOTFOUND/EAI_AGAIN/ECONNREFUSED/timeouts). The bridge
49
+ * then keeps the stdio side ALIVE and answers `initialize` IMMEDIATELY
50
+ * (design B), retrying the upstream connect with backoff (1s→2s→4s… capped
51
+ * 10s) for a 60s window. Why answer immediately: MCP clients cancel a
52
+ * pending `initialize` at ~60s (TS SDK DEFAULT_REQUEST_TIMEOUT_MSEC;
53
+ * Claude Desktop observed cancelling at ~60s in the wild) — a delayed
54
+ * initialize would lose the session the retry window is meant to save, and
55
+ * the first tools/list request carries the same ~60s client budget, so the
56
+ * window deliberately stays at the BOTTOM of the 60–90s range the issue
57
+ * proposed (evidence + decision: issue #501 design-note comment). The
58
+ * daemon's initialize-result `instructions` are unknowable while it is down
59
+ * and are therefore omitted on this path (the pre-#383 shape); tools
60
+ * handlers await upstream readiness. NEVER retried: 401/403 (auth is not
61
+ * transient — existing HICORTEX_AUTH_TOKEN hint) and a reachable-but-not-
62
+ * healthy endpoint (foreign service). Local targets keep the autostart poll
63
+ * (which already waits 30s). Mid-session SSE reconnect after an established
64
+ * connection drops is OUT OF SCOPE (#501 follow-up).
65
+ *
45
66
  * STDIO DISCIPLINE: stdout carries ONLY the MCP protocol. Every diagnostic
46
67
  * goes to stderr; fatal errors are a one-liner on stderr + non-zero exit
47
68
  * (thrown to cli.ts's catch). Cancellation downstream→upstream rides the
@@ -51,6 +72,7 @@
51
72
  * upstream request id (a verbatim forward would carry the downstream id,
52
73
  * which means nothing to the daemon) — and reject the in-flight bridge call.
53
74
  */
75
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
54
76
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
55
77
  /** Where the bridge target came from — surfaces in the startup diagnostic. */
56
78
  export type BridgeTargetSource = "option" | "env" | "config" | "default";
@@ -122,6 +144,16 @@ export interface EnsureDaemonOptions {
122
144
  * every fail-path (explicit error, never silent degradation).
123
145
  */
124
146
  export declare function ensureDaemonReady(target: BridgeTarget, options?: EnsureDaemonOptions): Promise<void>;
147
+ export type StartupFailureKind = "auth" | "foreign" | "transient" | "fatal";
148
+ /**
149
+ * Classify a startup failure of the upstream connect sequence. Only a REMOTE
150
+ * target's network-level failure is transient (#501); auth rejections and a
151
+ * reachable-but-unhealthy endpoint are fatal immediately, and local targets
152
+ * keep their own autostart-poll semantics.
153
+ */
154
+ export declare function classifyStartupFailure(err: unknown, target: BridgeTarget): StartupFailureKind;
155
+ /** Backoff step N (0-based): base·2^N, capped — 1s, 2s, 4s, 8s, then the cap. */
156
+ export declare function nextRetryDelayMs(attempt: number, baseMs: number, maxMs: number): number;
125
157
  export interface McpStdioOptions extends EnsureDaemonOptions {
126
158
  /** Explicit target URL (test seam; normally resolved from env/config). */
127
159
  serverUrl?: string;
@@ -129,10 +161,36 @@ export interface McpStdioOptions extends EnsureDaemonOptions {
129
161
  authToken?: string;
130
162
  /** Injectable downstream transport (test seam; default: real stdio). */
131
163
  downstream?: Transport;
164
+ /** Injectable upstream connect (test seam; default: real SSE transport). */
165
+ connectUpstream?: (target: BridgeTarget, token: string | undefined) => Promise<Client>;
166
+ /** Retry pacing for the #501 transient-unreachable window (test seams). */
167
+ retryWindowMs?: number;
168
+ retryBaseDelayMs?: number;
169
+ retryMaxDelayMs?: number;
132
170
  }
133
171
  /**
134
- * Run the stdio MCP bridge. Resolves only after the downstream transport
135
- * closes (the lifecycle handlers then exit the process); every setup failure
136
- * throws for cli.ts to report on stderr and exit 1.
172
+ * Connect the upstream Client to the daemon's SSE MCP endpoint. requestInit
173
+ * headers ride BOTH the GET /sse and the POST /messages (SDK 1.28
174
+ * _commonHeaders/send). Raw errors propagate — classification happens at the
175
+ * call site. Exported for the ghost-reconnect unit test.
176
+ *
177
+ * On failure the transport is closed EXPLICITLY: the SDK's Client.connect
178
+ * closes only when the initialize REQUEST fails after a successful start —
179
+ * a failed transport.start() (refused/DNS) propagates out of Protocol.connect
180
+ * with no cleanup, and the still-open EventSource keeps eventsource's ~3s
181
+ * reconnect loop alive. Every retry attempt would leak one ghost that, once
182
+ * the server appears, opens a REAL authed SSE session on the daemon and is
183
+ * never closed (proven empirically on SDK 1.28.0 / eventsource 3.0.7, PR
184
+ * review round 1 — pinned by the ghost-reconnect unit test).
185
+ */
186
+ export declare function defaultConnectUpstream(target: BridgeTarget, token: string | undefined): Promise<Client>;
187
+ /**
188
+ * Run the stdio MCP bridge. Fast path (daemon reachable now): connect
189
+ * upstream first, then serve stdio with the daemon's forwarded instructions
190
+ * — exactly the pre-#501 sequence. Slow path (REMOTE target, transient
191
+ * network failure — the boot race): serve stdio IMMEDIATELY (design B,
192
+ * initialize answered at once) and retry the upstream connect with backoff
193
+ * for the retry window. Resolves once bridging is established; every setup
194
+ * failure throws for cli.ts to report on stderr and exit 1.
137
195
  */
138
196
  export declare function runMcpStdio(options?: McpStdioOptions): Promise<void>;
package/dist/mcp-stdio.js CHANGED
@@ -43,6 +43,27 @@
43
43
  * port: explicit error, never a spawn. A remote target that is down is
44
44
  * likewise an explicit error — we never spawn for remote URLs.
45
45
  *
46
+ * Startup retry (#501): a REMOTE target that is unreachable at launch is
47
+ * TRANSIENT, not fatal — the product case is a client (e.g. Claude Desktop
48
+ * auto-launched at login) starting before the VPN/DNS that carries the
49
+ * server URL is up (ENOTFOUND/EAI_AGAIN/ECONNREFUSED/timeouts). The bridge
50
+ * then keeps the stdio side ALIVE and answers `initialize` IMMEDIATELY
51
+ * (design B), retrying the upstream connect with backoff (1s→2s→4s… capped
52
+ * 10s) for a 60s window. Why answer immediately: MCP clients cancel a
53
+ * pending `initialize` at ~60s (TS SDK DEFAULT_REQUEST_TIMEOUT_MSEC;
54
+ * Claude Desktop observed cancelling at ~60s in the wild) — a delayed
55
+ * initialize would lose the session the retry window is meant to save, and
56
+ * the first tools/list request carries the same ~60s client budget, so the
57
+ * window deliberately stays at the BOTTOM of the 60–90s range the issue
58
+ * proposed (evidence + decision: issue #501 design-note comment). The
59
+ * daemon's initialize-result `instructions` are unknowable while it is down
60
+ * and are therefore omitted on this path (the pre-#383 shape); tools
61
+ * handlers await upstream readiness. NEVER retried: 401/403 (auth is not
62
+ * transient — existing HICORTEX_AUTH_TOKEN hint) and a reachable-but-not-
63
+ * healthy endpoint (foreign service). Local targets keep the autostart poll
64
+ * (which already waits 30s). Mid-session SSE reconnect after an established
65
+ * connection drops is OUT OF SCOPE (#501 follow-up).
66
+ *
46
67
  * STDIO DISCIPLINE: stdout carries ONLY the MCP protocol. Every diagnostic
47
68
  * goes to stderr; fatal errors are a one-liner on stderr + non-zero exit
48
69
  * (thrown to cli.ts's catch). Cancellation downstream→upstream rides the
@@ -59,6 +80,9 @@ exports.resolveBridgeToken = resolveBridgeToken;
59
80
  exports.probeHealthOnce = probeHealthOnce;
60
81
  exports.decideAutostart = decideAutostart;
61
82
  exports.ensureDaemonReady = ensureDaemonReady;
83
+ exports.classifyStartupFailure = classifyStartupFailure;
84
+ exports.nextRetryDelayMs = nextRetryDelayMs;
85
+ exports.defaultConnectUpstream = defaultConnectUpstream;
62
86
  exports.runMcpStdio = runMcpStdio;
63
87
  const node_child_process_1 = require("node:child_process");
64
88
  const node_fs_1 = require("node:fs");
@@ -83,6 +107,14 @@ const DEFAULT_BRIDGE_PORT = 8787;
83
107
  const HEALTH_PROBE_TIMEOUT_MS = 2000;
84
108
  const AUTOSTART_POLL_INTERVAL_MS = 250;
85
109
  const AUTOSTART_POLL_TOTAL_MS = 30_000;
110
+ // #501 startup-retry window. 60s — the bottom of the issue's 60–90s proposal,
111
+ // deliberately: the client's own request timeout (TS SDK default, and Claude
112
+ // Desktop's observed initialize cancel) is 60s, and under design B the FIRST
113
+ // tools/list inherits that same budget, so a longer window would only answer
114
+ // requests the client has already abandoned.
115
+ const REMOTE_RETRY_WINDOW_MS = 60_000;
116
+ const REMOTE_RETRY_BASE_DELAY_MS = 1_000;
117
+ const REMOTE_RETRY_MAX_DELAY_MS = 10_000;
86
118
  /** Loopback check for URL hostnames (Node's URL keeps the brackets on [::1]). */
87
119
  function isLoopbackHost(hostname) {
88
120
  return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
@@ -160,6 +192,13 @@ async function probeHealthOnce(url, timeoutMs = HEALTH_PROBE_TIMEOUT_MS) {
160
192
  return { reachable: false, ok: false };
161
193
  }
162
194
  }
195
+ /** The clear unreachable message — shared by the fail-fast path and the #501
196
+ * retry-window expiry, so both ends of the window say the same thing. */
197
+ function remoteUnreachableMessage(target) {
198
+ return (`Cannot reach the Hicortex server at ${target.url}. Start it on the server machine ` +
199
+ `(check with \`hicortex status\`, start with \`npx @gamaze/hicortex server\`) or fix HICORTEX_SERVER_URL. ` +
200
+ `If it answers 401 once up, set HICORTEX_AUTH_TOKEN to the server's auth token.`);
201
+ }
163
202
  /**
164
203
  * Pure decision from one health probe: healthy → bridge; refused + loopback
165
204
  * → spawn a local daemon; refused + remote → fail with an actionable message
@@ -177,14 +216,8 @@ function decideAutostart(probe, target) {
177
216
  `then either free the port or point HICORTEX_SERVER_URL at the real Hicortex server.`,
178
217
  };
179
218
  }
180
- if (!target.local) {
181
- return {
182
- action: "fail",
183
- reason: `Cannot reach the Hicortex server at ${target.url}. Start it on the server machine ` +
184
- `(check with \`hicortex status\`, start with \`npx @gamaze/hicortex server\`) or fix HICORTEX_SERVER_URL. ` +
185
- `If it answers 401 once up, set HICORTEX_AUTH_TOKEN to the server's auth token.`,
186
- };
187
- }
219
+ if (!target.local)
220
+ return { action: "fail", reason: remoteUnreachableMessage(target) };
188
221
  return { action: "spawn" };
189
222
  }
190
223
  /**
@@ -234,49 +267,135 @@ async function ensureDaemonReady(target, options = {}) {
234
267
  `${Math.round(totalMs / 1000)}s of autostart. Try \`npx @gamaze/hicortex server\` in a terminal ` +
235
268
  `to see the daemon's startup error, then re-run this command.`);
236
269
  }
270
+ /** Node/undici errno codes a "network not up yet" boot race produces. */
271
+ const TRANSIENT_ERRNO_RE = /\b(?:ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|UND_ERR_CONNECT_TIMEOUT)\b/;
272
+ /** SseError carries the HTTP status on .code (SDK client/sse.js). */
273
+ function isAuthRejection(err) {
274
+ const code = err?.code;
275
+ return code === 401 || code === 403 || code === "401" || code === "403";
276
+ }
237
277
  /**
238
- * Run the stdio MCP bridge. Resolves only after the downstream transport
239
- * closes (the lifecycle handlers then exit the process); every setup failure
240
- * throws for cli.ts to report on stderr and exit 1.
278
+ * A network-level failure that a retry window can plausibly outlive: walk the
279
+ * error + its cause chain for a transient errno, a fetch/undici timeout name,
280
+ * or errno text embedded in the message (SseError has no `cause` — the SDK
281
+ * puts the underlying text straight into `SSE error: getaddrinfo ENOTFOUND …`).
241
282
  */
242
- async function runMcpStdio(options = {}) {
243
- const target = resolveBridgeTarget(options.serverUrl);
244
- const token = resolveBridgeToken(options.authToken);
245
- await ensureDaemonReady(target, options);
246
- // Upstream: the daemon's SSE MCP endpoint. requestInit headers ride BOTH
247
- // the GET /sse and the POST /messages (SDK 1.28 _commonHeaders/send).
283
+ function isTransientNetworkError(err) {
284
+ let current = err;
285
+ for (let depth = 0; depth < 5 && current instanceof Error; depth += 1) {
286
+ const code = current.code;
287
+ if (typeof code === "string" && TRANSIENT_ERRNO_RE.test(code))
288
+ return true;
289
+ if (current.name === "TimeoutError" || current.name === "AbortError")
290
+ return true;
291
+ if (TRANSIENT_ERRNO_RE.test(current.message))
292
+ return true;
293
+ current = current.cause;
294
+ }
295
+ return false;
296
+ }
297
+ /**
298
+ * Classify a startup failure of the upstream connect sequence. Only a REMOTE
299
+ * target's network-level failure is transient (#501); auth rejections and a
300
+ * reachable-but-unhealthy endpoint are fatal immediately, and local targets
301
+ * keep their own autostart-poll semantics.
302
+ */
303
+ function classifyStartupFailure(err, target) {
304
+ if (isAuthRejection(err))
305
+ return "auth";
306
+ const message = err instanceof Error ? err.message : String(err);
307
+ if (/not a healthy Hicortex/i.test(message))
308
+ return "foreign";
309
+ if (target.local)
310
+ return "fatal";
311
+ // decideAutostart's remote-unreachable reason is the probe-level shape of
312
+ // every refused/DNS-failed/timeout probe (probeHealthOnce collapses them).
313
+ if (message.startsWith("Cannot reach the Hicortex server"))
314
+ return "transient";
315
+ return isTransientNetworkError(err) ? "transient" : "fatal";
316
+ }
317
+ /** Backoff step N (0-based): base·2^N, capped — 1s, 2s, 4s, 8s, then the cap. */
318
+ function nextRetryDelayMs(attempt, baseMs, maxMs) {
319
+ return Math.min(baseMs * 2 ** attempt, maxMs);
320
+ }
321
+ /** One-line reason for the stderr retry log — never the full fail message. */
322
+ function summarizeStartupFailure(err) {
323
+ const message = err instanceof Error ? err.message : String(err);
324
+ const errno = TRANSIENT_ERRNO_RE.exec(message)?.[0];
325
+ if (errno)
326
+ return errno;
327
+ if (message.startsWith("Cannot reach the Hicortex server"))
328
+ return "unreachable";
329
+ return message.length > 80 ? `${message.slice(0, 77)}…` : message;
330
+ }
331
+ /** The friendly fatal form: auth rejections get the token hint, the rest pass
332
+ * through unchanged (their messages are already the actionable ones). */
333
+ function toStartupError(err, target) {
334
+ if (isAuthRejection(err)) {
335
+ const status = err.code;
336
+ return new Error(`The Hicortex server at ${target.url} rejected the connection (${status}). ` +
337
+ `Set HICORTEX_AUTH_TOKEN to the server's auth token — it is printed by \`hicortex status\` on the server box.`);
338
+ }
339
+ return err instanceof Error ? err : new Error(String(err));
340
+ }
341
+ /**
342
+ * Connect the upstream Client to the daemon's SSE MCP endpoint. requestInit
343
+ * headers ride BOTH the GET /sse and the POST /messages (SDK 1.28
344
+ * _commonHeaders/send). Raw errors propagate — classification happens at the
345
+ * call site. Exported for the ghost-reconnect unit test.
346
+ *
347
+ * On failure the transport is closed EXPLICITLY: the SDK's Client.connect
348
+ * closes only when the initialize REQUEST fails after a successful start —
349
+ * a failed transport.start() (refused/DNS) propagates out of Protocol.connect
350
+ * with no cleanup, and the still-open EventSource keeps eventsource's ~3s
351
+ * reconnect loop alive. Every retry attempt would leak one ghost that, once
352
+ * the server appears, opens a REAL authed SSE session on the daemon and is
353
+ * never closed (proven empirically on SDK 1.28.0 / eventsource 3.0.7, PR
354
+ * review round 1 — pinned by the ghost-reconnect unit test).
355
+ */
356
+ async function defaultConnectUpstream(target, token) {
248
357
  const upstream = new sse_js_1.SSEClientTransport(new URL(`${target.url}/sse`), token !== undefined ? { requestInit: { headers: { Authorization: `Bearer ${token}` } } } : {});
249
358
  const client = new index_js_2.Client({ name: "hicortex-mcp-bridge", version: VERSION });
250
359
  try {
251
360
  await client.connect(upstream);
252
361
  }
253
362
  catch (err) {
254
- // 401 from the daemon's auth middleware (remote connections; loopback is
255
- // exempt). SseError carries the HTTP status as .code.
256
- if (err.code === 401) {
257
- throw new Error(`The Hicortex server at ${target.url} rejected the connection (401). ` +
258
- `Set HICORTEX_AUTH_TOKEN to the server's auth token — it is printed by \`hicortex status\` on the server box.`);
259
- }
260
- throw err instanceof Error ? err : new Error(String(err));
363
+ await client.close().catch(() => { });
364
+ throw err;
261
365
  }
262
- // Downstream: a low-level Server over stdio advertising exactly what the
263
- // daemon offers (tools). Ping is auto-answered by the Protocol base.
264
- // #383: forward the DAEMON's initialize-result instructions verbatim — the
265
- // daemon owns the text and the memoryInstructions gate, so the two surfaces
266
- // cannot diverge and no config read is duplicated in the bridge (a
267
- // pre-#383 remote daemon simply has none to forward; undefined omits the
268
- // field from the bridge's own initialize result).
269
- const server = new index_js_1.Server({ name: "hicortex", version: VERSION }, { capabilities: { tools: {} }, instructions: client.getInstructions() });
366
+ return client;
367
+ }
368
+ /**
369
+ * The downstream Server: a low-level Server over stdio advertising exactly
370
+ * what the daemon offers (tools). Ping is auto-answered by the Protocol
371
+ * base. #383: `instructions` is the DAEMON's initialize-result text,
372
+ * forwarded verbatim — undefined (pre-#383 daemon, memoryInstructions off,
373
+ * or the #501 slow path where the daemon has not answered yet) omits the
374
+ * field. `awaitClient` yields the upstream Client a tools request should
375
+ * use — already-resolved on the fast path, a readiness promise on the slow
376
+ * path, so tools requests queue until the server exists.
377
+ */
378
+ function createBridgeServer(instructions, awaitClient) {
379
+ const server = new index_js_1.Server({ name: "hicortex", version: VERSION }, instructions !== undefined ? { capabilities: { tools: {} }, instructions } : { capabilities: { tools: {} } });
270
380
  // The proxy core — the SDK's documented proxy pattern. Forward the two
271
381
  // tools requests and pass extra.signal through so a downstream
272
382
  // notifications/cancelled aborts the upstream call (which emits the
273
383
  // correctly-id'd cancellation to the daemon). Nothing else is forwarded
274
384
  // request-wise: the daemon is tools-only and the base class answers ping.
275
- server.setRequestHandler(types_js_1.ListToolsRequestSchema, async (_request, extra) => (await client.listTools(undefined, { signal: extra.signal })));
276
- server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request, extra) => (await client.callTool(request.params, undefined, { signal: extra.signal })));
277
- // Upstream → downstream notifications, best-effort: the daemon's tool-list
278
- // changes or log messages reach the client; a closed far end must not kill
279
- // the bridge from inside a notification handler.
385
+ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async (_request, extra) => {
386
+ const client = await awaitClient();
387
+ return (await client.listTools(undefined, { signal: extra.signal }));
388
+ });
389
+ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request, extra) => {
390
+ const client = await awaitClient();
391
+ return (await client.callTool(request.params, undefined, { signal: extra.signal }));
392
+ });
393
+ return server;
394
+ }
395
+ /** Upstream → downstream notifications, best-effort: the daemon's tool-list
396
+ * changes or log messages reach the client; a closed far end must not kill
397
+ * the bridge from inside a notification handler. */
398
+ function forwardNotifications(client, server) {
280
399
  client.fallbackNotificationHandler = async (notification) => {
281
400
  try {
282
401
  await server.notification(notification);
@@ -285,29 +404,131 @@ async function runMcpStdio(options = {}) {
285
404
  // Best-effort by design.
286
405
  }
287
406
  };
288
- // Lifecycle: whichever side ends first tears down the other. The `exiting`
289
- // guard keeps our OWN client.close() (graceful path) from being read as an
290
- // upstream loss.
407
+ }
408
+ /**
409
+ * Lifecycle: whichever side ends first tears down the other. The `exiting`
410
+ * guard keeps our OWN client.close() (graceful path) from being read as an
411
+ * upstream loss. The upstream attaches late on the #501 slow path, hence
412
+ * attachUpstream() instead of a constructor argument.
413
+ */
414
+ function wireBridgeLifecycle(server) {
291
415
  let exiting = false;
416
+ let upstream;
292
417
  const shutdown = (code) => {
293
418
  if (exiting)
294
419
  return;
295
420
  exiting = true;
296
- void Promise.allSettled([server.close(), client.close()]).then(() => process.exit(code));
421
+ const closing = [server.close()];
422
+ if (upstream)
423
+ closing.push(upstream.close());
424
+ void Promise.allSettled(closing).then(() => process.exit(code));
297
425
  };
298
426
  // Downstream closed (the MCP client went away) → close upstream → exit 0.
299
427
  server.onclose = () => shutdown(0);
300
- // Upstream transport died → the bridge cannot serve anything → exit 1.
301
- client.onclose = () => {
302
- if (exiting)
303
- return;
304
- console.error("[hicortex] mcp: lost the connection to the Hicortex server");
305
- shutdown(1);
306
- };
307
428
  process.once("SIGINT", () => shutdown(0));
308
429
  process.once("SIGTERM", () => shutdown(0));
309
- const downstream = options.downstream ?? new stdio_js_1.StdioServerTransport();
310
- await server.connect(downstream);
311
- // Diagnostics NEVER touch stdout (the MCP wire) — stderr only.
430
+ return {
431
+ shutdown,
432
+ attachUpstream(client) {
433
+ upstream = client;
434
+ // Upstream transport died → the bridge cannot serve anything → exit 1.
435
+ client.onclose = () => {
436
+ if (exiting)
437
+ return;
438
+ console.error("[hicortex] mcp: lost the connection to the Hicortex server");
439
+ shutdown(1);
440
+ };
441
+ },
442
+ };
443
+ }
444
+ /**
445
+ * Run the stdio MCP bridge. Fast path (daemon reachable now): connect
446
+ * upstream first, then serve stdio with the daemon's forwarded instructions
447
+ * — exactly the pre-#501 sequence. Slow path (REMOTE target, transient
448
+ * network failure — the boot race): serve stdio IMMEDIATELY (design B,
449
+ * initialize answered at once) and retry the upstream connect with backoff
450
+ * for the retry window. Resolves once bridging is established; every setup
451
+ * failure throws for cli.ts to report on stderr and exit 1.
452
+ */
453
+ async function runMcpStdio(options = {}) {
454
+ const target = resolveBridgeTarget(options.serverUrl);
455
+ const token = resolveBridgeToken(options.authToken);
456
+ const connect = options.connectUpstream ?? defaultConnectUpstream;
457
+ // ---- Fast path: the daemon answers now. ----
458
+ let firstFailure;
459
+ let upstream;
460
+ try {
461
+ await ensureDaemonReady(target, options);
462
+ upstream = await connect(target, token);
463
+ }
464
+ catch (err) {
465
+ if (classifyStartupFailure(err, target) !== "transient")
466
+ throw toStartupError(err, target);
467
+ firstFailure = err;
468
+ }
469
+ if (upstream) {
470
+ const server = createBridgeServer(upstream.getInstructions(), async () => upstream);
471
+ const lifecycle = wireBridgeLifecycle(server);
472
+ lifecycle.attachUpstream(upstream);
473
+ forwardNotifications(upstream, server);
474
+ await server.connect(options.downstream ?? new stdio_js_1.StdioServerTransport());
475
+ // Diagnostics NEVER touch stdout (the MCP wire) — stderr only.
476
+ console.error(`[hicortex] mcp: bridging stdio <-> ${target.url}/sse (target: ${target.source})`);
477
+ return;
478
+ }
479
+ // ---- Slow path (#501): remote + transient — answer initialize now,
480
+ // retry the upstream in the background, keep stdio alive throughout. ----
481
+ const windowMs = options.retryWindowMs ?? REMOTE_RETRY_WINDOW_MS;
482
+ const baseDelayMs = options.retryBaseDelayMs ?? REMOTE_RETRY_BASE_DELAY_MS;
483
+ const maxDelayMs = options.retryMaxDelayMs ?? REMOTE_RETRY_MAX_DELAY_MS;
484
+ let resolveReady;
485
+ let rejectReady;
486
+ const upstreamReady = new Promise((resolve, reject) => {
487
+ resolveReady = resolve;
488
+ rejectReady = reject;
489
+ });
490
+ // Mark handled: if the window expires before any tools request arrived,
491
+ // rejecting an un-awaited promise would crash the process as an unhandled
492
+ // rejection.
493
+ upstreamReady.catch(() => { });
494
+ const server = createBridgeServer(undefined, () => upstreamReady);
495
+ const lifecycle = wireBridgeLifecycle(server);
496
+ await server.connect(options.downstream ?? new stdio_js_1.StdioServerTransport());
312
497
  console.error(`[hicortex] mcp: bridging stdio <-> ${target.url}/sse (target: ${target.source})`);
498
+ console.error(`[hicortex] mcp: Hicortex server at ${target.url} unreachable at startup ` +
499
+ `(${summarizeStartupFailure(firstFailure)}) — retrying for up to ${Math.round(windowMs / 1000)}s ` +
500
+ `while the network comes up; the connection stays open and tools wait for the server`);
501
+ const deadline = Date.now() + windowMs;
502
+ let attempt = 0;
503
+ let lastFailure = firstFailure;
504
+ for (;;) {
505
+ const remainingMs = deadline - Date.now();
506
+ if (remainingMs <= 0) {
507
+ rejectReady(new Error(remoteUnreachableMessage(target)));
508
+ throw new Error(remoteUnreachableMessage(target));
509
+ }
510
+ const delayMs = Math.min(nextRetryDelayMs(attempt, baseDelayMs, maxDelayMs), remainingMs);
511
+ console.error(`[hicortex] mcp: retrying ${target.url} in ${delayMs}ms ` +
512
+ `(attempt ${attempt + 1}, ${Math.ceil(remainingMs / 1000)}s of window left) after: ${summarizeStartupFailure(lastFailure)}`);
513
+ await sleep(delayMs);
514
+ attempt += 1;
515
+ try {
516
+ await ensureDaemonReady(target, { probeHealth: options.probeHealth });
517
+ const client = await connect(target, token);
518
+ // Established — from here the lifecycle is exactly the fast path's.
519
+ lifecycle.attachUpstream(client);
520
+ forwardNotifications(client, server);
521
+ resolveReady(client);
522
+ console.error(`[hicortex] mcp: server reachable after ${attempt} retry${attempt === 1 ? "" : "ies"} — serving tools`);
523
+ return;
524
+ }
525
+ catch (err) {
526
+ const kind = classifyStartupFailure(err, target);
527
+ if (kind === "auth")
528
+ throw toStartupError(err, target);
529
+ if (kind !== "transient")
530
+ throw err;
531
+ lastFailure = err;
532
+ }
533
+ }
313
534
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gamaze/hicortex",
3
- "version": "0.23.0",
3
+ "version": "0.23.1",
4
4
  "description": "Persistent agent identity for AI agents \u2014 a hand-edited identity layer, nightly-distilled experience, and lessons injected every session, shared across your whole fleet. Works with Hermes, OpenClaw, Claude Code, Pi, and opencode.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
package/server.json CHANGED
@@ -3,12 +3,12 @@
3
3
  "name": "io.github.gamaze-labs/hicortex",
4
4
  "title": "Hicortex \u2014 AI Fleet Memory",
5
5
  "description": "Shared fleet memory for AI agents: nightly self-correction, recall every prompt (supported agents).",
6
- "version": "0.23.0",
6
+ "version": "0.23.1",
7
7
  "packages": [
8
8
  {
9
9
  "registryType": "npm",
10
10
  "identifier": "@gamaze/hicortex",
11
- "version": "0.23.0",
11
+ "version": "0.23.1",
12
12
  "transport": {
13
13
  "type": "stdio",
14
14
  "command": "npx",