kojee-mcp 0.2.2 → 0.5.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.
@@ -1,6 +1,22 @@
1
+ import {
2
+ MCP_SESSION_ID,
3
+ createDPoPProof,
4
+ startEventStream
5
+ } from "./chunk-QB22PD6T.js";
6
+ import {
7
+ buildCatchUpNote,
8
+ buildMonitorSpawn,
9
+ buildReplyRecipe
10
+ } from "./chunk-E26AHU6J.js";
11
+ import {
12
+ deriveDiscoveryKey,
13
+ findClaudeAncestorPid
14
+ } from "./chunk-BJMASMKX.js";
15
+
1
16
  // src/index.ts
2
- import fs2 from "fs";
3
- import path2 from "path";
17
+ import fs3 from "fs";
18
+ import os2 from "os";
19
+ import path3 from "path";
4
20
 
5
21
  // src/auth/auth-module.ts
6
22
  import { calculateJwkThumbprint } from "jose";
@@ -9,12 +25,9 @@ import crypto from "crypto";
9
25
  // src/auth/keystore.ts
10
26
  import { importJWK, exportJWK, generateKeyPair } from "jose";
11
27
  import fs from "fs";
28
+ import os from "os";
12
29
  import path from "path";
13
- var DEFAULT_PATH = path.join(
14
- process.env["HOME"] ?? "~",
15
- ".kojee",
16
- "keypair.json"
17
- );
30
+ var DEFAULT_PATH = path.join(os.homedir(), ".kojee", "keypair.json");
18
31
  async function loadKeystore(keystorePath = DEFAULT_PATH, expectedBrokerUrl) {
19
32
  if (!fs.existsSync(keystorePath)) {
20
33
  return null;
@@ -187,34 +200,7 @@ var AuthModule = class {
187
200
  };
188
201
 
189
202
  // src/gateway-client.ts
190
- import crypto3 from "crypto";
191
-
192
- // src/auth/dpop.ts
193
- import { SignJWT, base64url } from "jose";
194
203
  import crypto2 from "crypto";
195
- async function createDPoPProof(privateKey, kid, method, url, nonce, accessToken) {
196
- const payload = {
197
- htm: method,
198
- htu: url,
199
- jti: crypto2.randomUUID()
200
- };
201
- if (nonce) {
202
- payload.nonce = nonce;
203
- }
204
- if (accessToken) {
205
- payload.ath = computeAth(accessToken);
206
- }
207
- const header = {
208
- typ: "dpop+jwt",
209
- alg: "ES256",
210
- jwk: { kid }
211
- };
212
- return new SignJWT(payload).setProtectedHeader(header).setIssuedAt().sign(privateKey);
213
- }
214
- function computeAth(accessToken) {
215
- const hash = crypto2.createHash("sha256").update(accessToken).digest();
216
- return base64url.encode(hash);
217
- }
218
204
 
219
205
  // src/error-translator.ts
220
206
  function translateGovernanceResult(result) {
@@ -278,9 +264,41 @@ function translateHttpError(status, errorCode, _trigger) {
278
264
  }
279
265
  return null;
280
266
  }
267
+ var TANDEM_ERROR_MESSAGES = {
268
+ [-32003]: () => "This Tandem is hardened to owner-only membership; you can't join.",
269
+ [-32004]: () => "You aren't a member of that Tandem. Use tandem_join(join_link) first.",
270
+ [-32006]: (data) => {
271
+ const retry = data?.["retry_after_seconds"] ?? "a moment";
272
+ return `Rate limit hit on this Tandem. Retry after ${retry} seconds.`;
273
+ },
274
+ [-32007]: (data) => {
275
+ const rule = data?.["rule"] ?? "policy";
276
+ return `Message rejected by Tandem policy (${rule}).`;
277
+ },
278
+ [-32011]: (data) => {
279
+ const id = data?.["tandem_id"] ?? "unknown";
280
+ return `Tandem ${id} doesn't exist or isn't visible to you.`;
281
+ },
282
+ [-32015]: (data) => {
283
+ const candidates = data?.["candidates"] ?? [];
284
+ return `An @-mention matched multiple members and is ambiguous. Retry with explicit mentions[]. Candidates: ${candidates.join(", ")}.`;
285
+ }
286
+ };
281
287
  function translateJsonRpcError(error) {
282
288
  const msg = error.message ?? "";
283
289
  const msgLower = msg.toLowerCase();
290
+ const tandemMessage = TANDEM_ERROR_MESSAGES[error.code];
291
+ if (tandemMessage) {
292
+ return {
293
+ content: [
294
+ {
295
+ type: "text",
296
+ text: tandemMessage(error.data)
297
+ }
298
+ ],
299
+ isError: true
300
+ };
301
+ }
284
302
  switch (error.code) {
285
303
  case -32601:
286
304
  return makeError(
@@ -365,31 +383,53 @@ var GatewayClient = class {
365
383
  currentNonce;
366
384
  requestCounter = 0;
367
385
  endpoint;
386
+ /**
387
+ * Expose the DPoP signing key so peer modules sharing auth state
388
+ * (e.g. tandem/event-stream.ts) can sign their own requests.
389
+ */
390
+ getPrivateKey() {
391
+ return this.privateKey;
392
+ }
393
+ /**
394
+ * Expose the bot_key_id (kid) for DPoP proof headers. Paired with
395
+ * getPrivateKey() so peer modules can construct proofs without
396
+ * threading the key material through their own constructors.
397
+ */
398
+ getKid() {
399
+ return this.kid;
400
+ }
368
401
  /**
369
402
  * Derive a deterministic session ID from the gateway token.
370
403
  * session_id = sha256(token + "proxy").slice(0, 16)
371
404
  */
372
405
  static deriveSessionId(token) {
373
- const hash = crypto3.createHash("sha256").update(token + "proxy").digest("hex");
406
+ const hash = crypto2.createHash("sha256").update(token + "proxy").digest("hex");
374
407
  return hash.slice(0, 16);
375
408
  }
376
409
  /**
377
410
  * Send a JSON-RPC 2.0 request to the gateway, handling DPoP auth,
378
411
  * nonce retry, and step-up retry transparently.
412
+ *
413
+ * `signal` (ROUND-3 MAJOR A) is a REAL AbortSignal threaded into the
414
+ * underlying `fetch` option — NOT placed inside `params`/`arguments`. A
415
+ * caller with a per-call timeout budget (e.g. resubscribeMemberships) passes
416
+ * its controller's signal here so a hung backend aborts at the budget instead
417
+ * of hanging forever. Putting the signal in `arguments` (the round-2 bug) both
418
+ * left fetch un-aborted AND serialized a junk `{}` onto the wire body.
379
419
  */
380
- async sendRpc(method, params = {}) {
420
+ async sendRpc(method, params = {}, signal) {
381
421
  const rpcRequest = {
382
422
  jsonrpc: "2.0",
383
423
  id: ++this.requestCounter,
384
424
  method,
385
425
  params
386
426
  };
387
- return this.executeWithRetries(rpcRequest);
427
+ return this.executeWithRetries(rpcRequest, signal);
388
428
  }
389
- async executeWithRetries(rpcRequest) {
429
+ async executeWithRetries(rpcRequest, signal) {
390
430
  let response;
391
431
  try {
392
- response = await this.sendHttpRequest(rpcRequest);
432
+ response = await this.sendHttpRequest(rpcRequest, signal);
393
433
  } catch (err) {
394
434
  return translateNetworkError(err);
395
435
  }
@@ -399,7 +439,7 @@ var GatewayClient = class {
399
439
  if (body?.error === "use_dpop_nonce") {
400
440
  console.error("[gateway] Nonce expired, retrying with fresh nonce...");
401
441
  try {
402
- response = await this.sendHttpRequest(rpcRequest);
442
+ response = await this.sendHttpRequest(rpcRequest, signal);
403
443
  } catch (err) {
404
444
  return translateNetworkError(err);
405
445
  }
@@ -412,7 +452,7 @@ var GatewayClient = class {
412
452
  if (response.status === 403) {
413
453
  const body = await this.tryParseErrorBody(response);
414
454
  if (body?.error === "step_up_required") {
415
- return this.handleStepUp(rpcRequest, body.trigger);
455
+ return this.handleStepUp(rpcRequest, body.trigger, signal);
416
456
  }
417
457
  const translated = translateHttpError(403, body?.error, body?.trigger);
418
458
  if (translated) return translated;
@@ -433,7 +473,7 @@ var GatewayClient = class {
433
473
  const result = rpcResponse.result;
434
474
  return result ?? { content: [{ type: "text", text: "No result" }] };
435
475
  }
436
- async sendHttpRequest(rpcRequest) {
476
+ async sendHttpRequest(rpcRequest, signal) {
437
477
  const proof = await createDPoPProof(
438
478
  this.privateKey,
439
479
  this.kid,
@@ -447,16 +487,21 @@ var GatewayClient = class {
447
487
  headers: {
448
488
  "Content-Type": "application/json",
449
489
  Authorization: `DPoP ${this.token}`,
450
- DPoP: proof
490
+ DPoP: proof,
491
+ "Mcp-Session-Id": MCP_SESSION_ID
451
492
  },
452
- body: JSON.stringify(rpcRequest)
493
+ body: JSON.stringify(rpcRequest),
494
+ // ROUND-3 MAJOR A: the caller's AbortSignal rides HERE (a real fetch
495
+ // option), never inside the JSON-RPC body. `undefined` is a valid value
496
+ // for the fetch `signal` option (no abort wired).
497
+ ...signal ? { signal } : {}
453
498
  });
454
499
  }
455
500
  /**
456
501
  * Handle step-up retry: poll with backoff until user approves
457
502
  * or timeout is reached.
458
503
  */
459
- async handleStepUp(rpcRequest, trigger) {
504
+ async handleStepUp(rpcRequest, trigger, signal) {
460
505
  console.error(
461
506
  `[gateway] Device re-authorization required (${trigger ?? "unknown"}). Waiting for user approval...`
462
507
  );
@@ -465,7 +510,7 @@ var GatewayClient = class {
465
510
  await sleep(STEP_UP_POLL_INTERVAL_MS);
466
511
  let response;
467
512
  try {
468
- response = await this.sendHttpRequest(rpcRequest);
513
+ response = await this.sendHttpRequest(rpcRequest, signal);
469
514
  } catch {
470
515
  continue;
471
516
  }
@@ -591,16 +636,53 @@ import {
591
636
  ListToolsRequestSchema,
592
637
  CallToolRequestSchema
593
638
  } from "@modelcontextprotocol/sdk/types.js";
594
- function createMcpServer(registry) {
639
+
640
+ // src/version.ts
641
+ import fs2 from "fs";
642
+ import path2 from "path";
643
+ import { fileURLToPath } from "url";
644
+ var FALLBACK_VERSION = "0.0.0-unknown";
645
+ function resolveVersion() {
646
+ try {
647
+ const here = path2.dirname(fileURLToPath(import.meta.url));
648
+ const parsed = JSON.parse(
649
+ fs2.readFileSync(path2.join(here, "..", "package.json"), "utf8")
650
+ );
651
+ return typeof parsed?.version === "string" && parsed.version ? parsed.version : FALLBACK_VERSION;
652
+ } catch (err) {
653
+ process.stderr.write(
654
+ `kojee-mcp: could not resolve version from package.json, falling back to ${FALLBACK_VERSION}: ${String(err)}
655
+ `
656
+ );
657
+ return FALLBACK_VERSION;
658
+ }
659
+ }
660
+ var VERSION = resolveVersion();
661
+
662
+ // src/server.ts
663
+ function buildChannelInstructions(_tandemMembershipCount, eventLogPath) {
664
+ const intro = `Tandem events are delivered to you in three ways depending on which is active in your Claude Code session:
665
+
666
+ (1) If channel notifications are available, you'll see them as \`<channel source="kojee-mcp" ...>\` tags in your context. Each tag carries tandem_id, message_id, from_principal, kind, severity. To respond: ${buildReplyRecipe()}.
667
+
668
+ `;
669
+ const monitorSection = `(2) For sustained wake-from-idle WITHOUT channel notifications, the proxy writes one event line per Tandem message to ${eventLogPath}. At the start of every session, spawn ONE Monitor task: ${buildMonitorSpawn(eventLogPath)}. Each appended line will arrive as a separate wake notification, and carries msg=<id> and cursor=<n>. To respond: ${buildReplyRecipe()}. ${buildCatchUpNote()} (\`kojee-mcp tail\` is a portable line-streamer shipped with this proxy \u2014 works on macOS, Linux, and Windows. It follows BOTH the messages log above and a status sibling; status/heartbeat telemetry never wakes you \u2014 only real messages do.)
670
+
671
+ `;
672
+ const listenSection = "(3) If you want to BLOCK until any single reply lands (rather than receive a stream of events), call tandem_listen(tandem_id, since=cursor, timeout_ms=N) instead.";
673
+ const advice = "\n\nPrefer (2) at session start \u2014 it's the default no-allowlist wake mechanism. (1) supplements it when channels are enabled; (3) is for one-shot blocking waits.";
674
+ return intro + monitorSection + listenSection + advice;
675
+ }
676
+ function createMcpServer(registry, adapter, tandemMembershipCount = -1, eventLogPath) {
677
+ const capabilities = { tools: {} };
678
+ if (adapter.supportsChannels) {
679
+ capabilities.experimental = { "claude/channel": {} };
680
+ }
595
681
  const server = new Server(
682
+ { name: "kojee-mcp", version: VERSION },
596
683
  {
597
- name: "kojee-mcp",
598
- version: "0.2.0"
599
- },
600
- {
601
- capabilities: {
602
- tools: {}
603
- }
684
+ capabilities,
685
+ ...adapter.supportsChannels ? { instructions: buildChannelInstructions(tandemMembershipCount, eventLogPath ?? "") } : {}
604
686
  }
605
687
  );
606
688
  server.setRequestHandler(ListToolsRequestSchema, async () => {
@@ -615,10 +697,7 @@ function createMcpServer(registry) {
615
697
  const { name, arguments: args } = request.params;
616
698
  const rawResult = await registry.callTool(name, args ?? {});
617
699
  const result = translateToolCallResult(rawResult);
618
- return {
619
- content: result.content,
620
- isError: result.isError
621
- };
700
+ return { content: result.content, isError: result.isError };
622
701
  });
623
702
  return server;
624
703
  }
@@ -628,30 +707,236 @@ async function startMcpServer(server) {
628
707
  console.error("[mcp] Server started on stdio transport");
629
708
  }
630
709
 
710
+ // src/runtime/detect.ts
711
+ import psList from "ps-list";
712
+ async function detectRuntime(env = process.env) {
713
+ if (env["KOJEE_RUNTIME"] === "claude-code") return "claude-code";
714
+ if (env["CLAUDE_CODE_SESSION_ID"]) return "claude-code";
715
+ if (await parentProcessLooksLikeClaude()) return "claude-code";
716
+ return "unknown";
717
+ }
718
+ async function parentProcessLooksLikeClaude() {
719
+ try {
720
+ const processes = await psList();
721
+ const byPid = new Map(processes.map((p) => [p.pid, p]));
722
+ let pid = process.ppid;
723
+ for (let depth = 0; depth < 5 && pid && pid > 1; depth++) {
724
+ const row = byPid.get(pid);
725
+ if (!row) return false;
726
+ const haystack = `${row.name} ${row.cmd ?? ""}`;
727
+ if (/(^|\/|\\)claude(\.app|\.exe)?(\/|$|\s|\\)/i.test(haystack) || /Claude Helper/i.test(haystack)) {
728
+ return true;
729
+ }
730
+ pid = row.ppid;
731
+ }
732
+ } catch {
733
+ }
734
+ return false;
735
+ }
736
+
737
+ // src/adapters/claude-code.ts
738
+ function computeSeverity(event) {
739
+ if (event.type === "state_change") return "high";
740
+ if (event.mentions && event.mentions.length > 0) {
741
+ return "high";
742
+ }
743
+ return "normal";
744
+ }
745
+ function formatBody(event) {
746
+ if (event.type === "state_change") {
747
+ return `[Tandem: ${event.tandem_id}] ${event.from.displayname}: ${event.content.body}`;
748
+ }
749
+ return [
750
+ `[Tandem: ${event.tandem_id}] ${event.from.displayname} (${event.from.principal}) \u2014 ${event.kind}:`,
751
+ event.content.body,
752
+ "",
753
+ `> ${buildReplyRecipe({ tandem_id: event.tandem_id, message_id: event.id })}`
754
+ ].join("\n");
755
+ }
756
+ var claudeCodeAdapter = {
757
+ runtime: "claude-code",
758
+ supportsChannels: true,
759
+ formatTandemEvent(event) {
760
+ const meta = {
761
+ tandem_id: event.tandem_id,
762
+ message_id: event.id,
763
+ cursor: String(event.cursor),
764
+ kind: event.kind,
765
+ from_principal: event.from.principal,
766
+ from_display: event.from.displayname,
767
+ severity: computeSeverity(event)
768
+ };
769
+ return { content: formatBody(event), meta };
770
+ }
771
+ };
772
+
773
+ // src/adapters/unknown.ts
774
+ var unknownAdapter = {
775
+ runtime: "unknown",
776
+ supportsChannels: false,
777
+ formatTandemEvent() {
778
+ throw new Error(
779
+ "unknownAdapter.formatTandemEvent() is unreachable \u2014 caller should gate on supportsChannels"
780
+ );
781
+ }
782
+ };
783
+
631
784
  // src/index.ts
632
- var DEFAULT_KEYSTORE_PATH = path2.join(
633
- process.env["HOME"] ?? "~",
634
- ".kojee",
635
- "keypair.json"
636
- );
785
+ var DEFAULT_KEYSTORE_PATH = path3.join(os2.homedir(), ".kojee", "keypair.json");
637
786
  function isDPoPEnrollmentError(err) {
638
787
  const msg = String(err?.message ?? err ?? "").toLowerCase();
639
788
  if (msg.includes("invalid or expired") && msg.includes("token")) return false;
640
789
  if (msg.includes("generate a new")) return false;
641
790
  return msg.includes("401") || msg.includes("authentication failed") || msg.includes("dpop") || msg.includes("key enrollment") || msg.includes("kid");
642
791
  }
792
+ async function selectAdapter() {
793
+ const runtime = await detectRuntime();
794
+ if (runtime === "claude-code") return claudeCodeAdapter;
795
+ return unknownAdapter;
796
+ }
797
+ async function listTandemIds(gateway) {
798
+ const result = await gateway.sendRpc("tools/call", { name: "tandem_list", arguments: {} });
799
+ const maybeErr = result;
800
+ if (maybeErr.isError) return null;
801
+ const text = maybeErr.content?.[0]?.text;
802
+ try {
803
+ const parsed = text ? JSON.parse(text) : {};
804
+ const list = Array.isArray(parsed.tandems) ? parsed.tandems : Array.isArray(parsed) ? parsed : null;
805
+ if (!Array.isArray(list)) return [];
806
+ return list.map((t) => {
807
+ if (typeof t === "string") return t;
808
+ const obj = t;
809
+ return obj?.tandem_id ?? obj?.id;
810
+ }).filter((id) => typeof id === "string" && id.length > 0);
811
+ } catch {
812
+ return null;
813
+ }
814
+ }
643
815
  async function startProxy(config) {
644
816
  const keystorePath = config.keystorePath || DEFAULT_KEYSTORE_PATH;
645
- console.error(`[kojee-mcp] Starting proxy for ${config.url}`);
646
- const registry = await enrollAndDiscover(config, keystorePath);
817
+ const adapter = await selectAdapter();
818
+ console.error(`[kojee-mcp] Starting proxy for ${config.url} (runtime=${adapter.runtime})`);
819
+ const { registry, gateway } = await enrollAndDiscover(config, keystorePath);
647
820
  console.error(
648
821
  `[kojee-mcp] Ready \u2014 ${registry.toolCount} tools available from ${config.url}`
649
822
  );
650
- const server = createMcpServer(registry);
651
- process.stdin.on("end", () => {
652
- console.error("[kojee-mcp] stdin closed, exiting");
653
- process.exit(0);
654
- });
823
+ let tandemMembershipCount = -1;
824
+ try {
825
+ const bootIds = await listTandemIds(gateway);
826
+ tandemMembershipCount = bootIds === null ? -1 : bootIds.length;
827
+ } catch (err) {
828
+ console.error("[kojee-mcp] tandem_list probe failed:", err.message);
829
+ }
830
+ console.error(`[kojee-mcp] Tandem memberships: ${tandemMembershipCount === -1 ? "unknown" : tandemMembershipCount}`);
831
+ let server;
832
+ if (adapter.supportsChannels) {
833
+ const { EventQueue } = await import("./event-queue-5YVJFR3E.js");
834
+ const { startHookServer } = await import("./hook-server-QF5JVUHV.js");
835
+ const {
836
+ writeDiscoveryByKey,
837
+ cleanupDiscoveryByKey,
838
+ sweepStaleDiscovery
839
+ } = await import("./session-discovery-QE5TTAPS.js");
840
+ const { startEventLog, sweepStaleEventLogs } = await import("./event-log-R6VW6GAF.js");
841
+ const { resubscribeMemberships } = await import("./resubscribe-SLZNA76S.js");
842
+ sweepStaleDiscovery();
843
+ sweepStaleEventLogs();
844
+ const ccPid = await findClaudeAncestorPid();
845
+ const projectDir = process.env["CLAUDE_PROJECT_DIR"];
846
+ const discoveryKey = deriveDiscoveryKey(projectDir, ccPid);
847
+ const eventLog = startEventLog({ key: discoveryKey });
848
+ server = createMcpServer(registry, adapter, tandemMembershipCount, eventLog.path);
849
+ const queue = new EventQueue();
850
+ let streamHandle = null;
851
+ const hookServer = await startHookServer({
852
+ port: 0,
853
+ queue,
854
+ adapter,
855
+ getStreamState: () => streamHandle ? streamHandle.getState() : {
856
+ connected: false,
857
+ connectedSince: null,
858
+ lastEventAt: null,
859
+ lastHeartbeatAt: null,
860
+ cursors: {},
861
+ reconnectCount: 0,
862
+ // Adaptive: unknown until the watchdog observes ≥2 heartbeats.
863
+ staleAfterMs: null
864
+ }
865
+ });
866
+ writeDiscoveryByKey(discoveryKey, {
867
+ schema: 2,
868
+ discoveryKey,
869
+ ccPid,
870
+ projectDir: projectDir ?? null,
871
+ proxyPid: process.pid,
872
+ // Legacy `pid` mirrors `proxyPid` so the existing event-log sweep
873
+ // (which reads `data.pid`) still considers this entry live.
874
+ pid: process.pid,
875
+ port: hookServer.port,
876
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
877
+ brokerUrl: config.url,
878
+ eventLogPath: eventLog.path
879
+ });
880
+ const cleanupDiscoveryFile = () => cleanupDiscoveryByKey(discoveryKey);
881
+ process.on("exit", () => cleanupDiscoveryFile());
882
+ process.on("SIGINT", () => {
883
+ cleanupDiscoveryFile();
884
+ process.exit(0);
885
+ });
886
+ process.on("SIGTERM", () => {
887
+ cleanupDiscoveryFile();
888
+ process.exit(0);
889
+ });
890
+ process.on("exit", () => eventLog.cleanup());
891
+ streamHandle = await startEventStream({
892
+ brokerUrl: config.url,
893
+ token: config.token,
894
+ gateway,
895
+ adapter,
896
+ server,
897
+ queue,
898
+ eventLog,
899
+ // Resubscribe-on-start (P0 #2): touch all memberships + write a
900
+ // `status=subscribed n=<count>` line on every (re)connect, so a backend
901
+ // restart / scope reset self-heals and the log is never ambiguously
902
+ // empty. See resubscribe.ts for the unverified-touch caveat.
903
+ //
904
+ // MINOR 6: `listTandems` re-fetches the membership list per reconnect (a
905
+ // mid-session join is touched next reconnect, not boot-frozen), each
906
+ // touch is timeout-bounded + run with bounded concurrency, and the whole
907
+ // routine runs concurrently with consumeSse (never blocks first-event
908
+ // delivery). `listTandemIds` may return null (unknown) → treat as empty.
909
+ // MINOR E: a shared debounce cursor damps a connect/drop flap storm — a
910
+ // resubscribe within 30s of the last successful one is skipped.
911
+ onConnected: /* @__PURE__ */ (() => {
912
+ const debounceState = { lastRunAt: 0 };
913
+ return async () => {
914
+ await resubscribeMemberships({
915
+ gateway,
916
+ eventLog,
917
+ listTandems: async () => await listTandemIds(gateway) ?? [],
918
+ debounceState
919
+ });
920
+ };
921
+ })()
922
+ });
923
+ const cancelStream = streamHandle;
924
+ process.stdin.on("end", () => {
925
+ cancelStream();
926
+ cleanupDiscoveryFile();
927
+ eventLog.cleanup();
928
+ hookServer.stop().finally(() => {
929
+ console.error("[kojee-mcp] stdin closed, exiting");
930
+ process.exit(0);
931
+ });
932
+ });
933
+ } else {
934
+ server = createMcpServer(registry, adapter, tandemMembershipCount);
935
+ process.stdin.on("end", () => {
936
+ console.error("[kojee-mcp] stdin closed, exiting");
937
+ process.exit(0);
938
+ });
939
+ }
655
940
  await startMcpServer(server);
656
941
  }
657
942
  async function enrollAndDiscover(config, keystorePath, isRetry = false) {
@@ -669,7 +954,7 @@ async function enrollAndDiscover(config, keystorePath, isRetry = false) {
669
954
  const registry = new ToolRegistry(gateway);
670
955
  try {
671
956
  await registry.discoverTools();
672
- return registry;
957
+ return { registry, gateway };
673
958
  } catch (err) {
674
959
  if (isRetry || !isDPoPEnrollmentError(err)) {
675
960
  throw err;
@@ -678,9 +963,7 @@ async function enrollAndDiscover(config, keystorePath, isRetry = false) {
678
963
  "[kojee-mcp] Auth failed, attempting recovery with fresh enrollment..."
679
964
  );
680
965
  try {
681
- if (fs2.existsSync(keystorePath)) {
682
- fs2.unlinkSync(keystorePath);
683
- }
966
+ if (fs3.existsSync(keystorePath)) fs3.unlinkSync(keystorePath);
684
967
  } catch (unlinkErr) {
685
968
  console.error("[kojee-mcp] Could not remove stale keystore:", unlinkErr);
686
969
  }
@@ -689,5 +972,7 @@ async function enrollAndDiscover(config, keystorePath, isRetry = false) {
689
972
  }
690
973
 
691
974
  export {
975
+ AuthModule,
976
+ VERSION,
692
977
  startProxy
693
978
  };
@@ -0,0 +1,75 @@
1
+ // src/hooks/hook-input.ts
2
+ var MAX_STDIN_BYTES = 1024 * 1024;
3
+ var IDLE_TIMEOUT_MS = 50;
4
+ var NULL_RESULT = {
5
+ sessionId: null,
6
+ transcriptPath: null,
7
+ hookEventName: null,
8
+ stopHookActive: false,
9
+ raw: ""
10
+ };
11
+ async function readHookStdin() {
12
+ const raw = await drainStdinBuffered();
13
+ if (raw === null) {
14
+ return { ...NULL_RESULT };
15
+ }
16
+ if (raw === "") {
17
+ return { ...NULL_RESULT };
18
+ }
19
+ try {
20
+ const parsed = JSON.parse(raw);
21
+ return {
22
+ sessionId: stringOrNull(parsed["session_id"]),
23
+ transcriptPath: stringOrNull(parsed["transcript_path"]),
24
+ hookEventName: stringOrNull(parsed["hook_event_name"]),
25
+ stopHookActive: parsed["stop_hook_active"] === true,
26
+ raw
27
+ };
28
+ } catch {
29
+ return {
30
+ sessionId: null,
31
+ transcriptPath: null,
32
+ hookEventName: null,
33
+ stopHookActive: false,
34
+ raw
35
+ };
36
+ }
37
+ }
38
+ function stringOrNull(value) {
39
+ return typeof value === "string" ? value : null;
40
+ }
41
+ function drainStdinBuffered() {
42
+ return new Promise((resolve) => {
43
+ const chunks = [];
44
+ let total = 0;
45
+ let overflowed = false;
46
+ let settled = false;
47
+ const finish = (value) => {
48
+ if (settled) return;
49
+ settled = true;
50
+ resolve(value);
51
+ };
52
+ const onData = (chunk) => {
53
+ if (overflowed) return;
54
+ total += chunk.length;
55
+ if (total > MAX_STDIN_BYTES) {
56
+ overflowed = true;
57
+ chunks.length = 0;
58
+ finish(null);
59
+ return;
60
+ }
61
+ chunks.push(chunk);
62
+ };
63
+ process.stdin.on("data", onData);
64
+ process.stdin.on("end", () => finish(Buffer.concat(chunks).toString("utf8")));
65
+ process.stdin.on("error", () => finish(Buffer.concat(chunks).toString("utf8")));
66
+ setTimeout(
67
+ () => finish(Buffer.concat(chunks).toString("utf8")),
68
+ IDLE_TIMEOUT_MS
69
+ );
70
+ });
71
+ }
72
+
73
+ export {
74
+ readHookStdin
75
+ };