kojee-mcp 0.4.0 → 0.5.2

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 (38) hide show
  1. package/README.md +98 -10
  2. package/dist/chunk-2TUAFAIW.js +244 -0
  3. package/dist/{chunk-36DMIXH7.js → chunk-BJMASMKX.js} +13 -23
  4. package/dist/chunk-BLEGIR35.js +43 -0
  5. package/dist/chunk-C6GZ2L2W.js +38 -0
  6. package/dist/{chunk-VZVGTHGF.js → chunk-DO42NPNR.js} +11 -17
  7. package/dist/chunk-EW72ZNQL.js +39 -0
  8. package/dist/chunk-F7L25L2J.js +60 -0
  9. package/dist/{chunk-WHTH6WBP.js → chunk-LSUB6QMP.js} +3 -0
  10. package/dist/chunk-LVL25VLO.js +22 -0
  11. package/dist/chunk-SQL56SEB.js +14 -0
  12. package/dist/chunk-WBMX4CHB.js +378 -0
  13. package/dist/{chunk-ZGVUM4AG.js → chunk-YEC7IHIG.js} +276 -318
  14. package/dist/{chunk-E7TE4QZD.js → chunk-YH27B6SW.js} +9 -9
  15. package/dist/chunk-ZW4SW7LJ.js +225 -0
  16. package/dist/cli.js +70 -78
  17. package/dist/codex-stop-hook-JOTBCS5K.js +72 -0
  18. package/dist/doctor-TSHOMT5X.js +237 -0
  19. package/dist/doctor-codex-BMI5JOO6.js +130 -0
  20. package/dist/event-log-RSTM4PLL.js +18 -0
  21. package/dist/{hook-server-43QS7L7P.js → hook-server-QF5JVUHV.js} +28 -0
  22. package/dist/index.d.ts +9 -0
  23. package/dist/index.js +5 -2
  24. package/dist/{install-WV25CRU2.js → install-WBIUVBZW.js} +9 -7
  25. package/dist/{paired-config-OAR3O3XY.js → paired-config-JTFLHMZ2.js} +2 -1
  26. package/dist/resubscribe-SLZNA76S.js +59 -0
  27. package/dist/runtime-record-WO4IECM6.js +14 -0
  28. package/dist/runtimes-CO43XUUK.js +12 -0
  29. package/dist/{session-discovery-WSHLR4OV.js → session-discovery-FNMJGFPM.js} +2 -1
  30. package/dist/stop-hook-SEPWWETV.js +119 -0
  31. package/dist/tail-stream-BYKO4DW6.js +162 -0
  32. package/dist/{user-prompt-submit-hook-WSRIJVF4.js → user-prompt-submit-hook-ARPEO6FF.js} +5 -4
  33. package/dist/webhook-config-5TLLX7RA.js +10 -0
  34. package/dist/webhook-sink-7OYZBWXA.js +163 -0
  35. package/dist/wizard-7KHD5JT4.js +265 -0
  36. package/package.json +9 -7
  37. package/dist/event-log-ETWR6PPY.js +0 -112
  38. package/dist/stop-hook-5XU3EQAE.js +0 -76
@@ -1,11 +1,26 @@
1
1
  import {
2
2
  deriveDiscoveryKey,
3
3
  findClaudeAncestorPid
4
- } from "./chunk-36DMIXH7.js";
4
+ } from "./chunk-BJMASMKX.js";
5
+ import {
6
+ buildCatchUpNote,
7
+ buildMonitorSpawn,
8
+ buildReplyRecipe
9
+ } from "./chunk-C6GZ2L2W.js";
10
+ import {
11
+ MCP_SESSION_ID,
12
+ createDPoPProof,
13
+ startEventStream
14
+ } from "./chunk-WBMX4CHB.js";
15
+ import {
16
+ secureDir,
17
+ secureFile
18
+ } from "./chunk-BLEGIR35.js";
5
19
 
6
20
  // src/index.ts
7
- import fs2 from "fs";
8
- import path2 from "path";
21
+ import fs3 from "fs";
22
+ import os2 from "os";
23
+ import path3 from "path";
9
24
 
10
25
  // src/auth/auth-module.ts
11
26
  import { calculateJwkThumbprint } from "jose";
@@ -14,12 +29,9 @@ import crypto from "crypto";
14
29
  // src/auth/keystore.ts
15
30
  import { importJWK, exportJWK, generateKeyPair } from "jose";
16
31
  import fs from "fs";
32
+ import os from "os";
17
33
  import path from "path";
18
- var DEFAULT_PATH = path.join(
19
- process.env["HOME"] ?? "~",
20
- ".kojee",
21
- "keypair.json"
22
- );
34
+ var DEFAULT_PATH = path.join(os.homedir(), ".kojee", "keypair.json");
23
35
  async function loadKeystore(keystorePath = DEFAULT_PATH, expectedBrokerUrl) {
24
36
  if (!fs.existsSync(keystorePath)) {
25
37
  return null;
@@ -39,9 +51,8 @@ async function loadKeystore(keystorePath = DEFAULT_PATH, expectedBrokerUrl) {
39
51
  }
40
52
  async function saveKeystore(privateKey, publicJwk, kid, brokerUrl, keystorePath = DEFAULT_PATH) {
41
53
  const dir = path.dirname(keystorePath);
42
- if (!fs.existsSync(dir)) {
43
- fs.mkdirSync(dir, { recursive: true, mode: 448 });
44
- }
54
+ fs.mkdirSync(dir, { recursive: true, mode: 448 });
55
+ secureDir(dir);
45
56
  const privateJwk = await exportJWK(privateKey);
46
57
  const data = {
47
58
  private_key_jwk: privateJwk,
@@ -53,6 +64,7 @@ async function saveKeystore(privateKey, publicJwk, kid, brokerUrl, keystorePath
53
64
  fs.writeFileSync(keystorePath, JSON.stringify(data, null, 2), {
54
65
  mode: 384
55
66
  });
67
+ secureFile(keystorePath);
56
68
  }
57
69
  async function generateES256KeyPair() {
58
70
  const { privateKey, publicKey } = await generateKeyPair("ES256");
@@ -192,34 +204,7 @@ var AuthModule = class {
192
204
  };
193
205
 
194
206
  // src/gateway-client.ts
195
- import crypto3 from "crypto";
196
-
197
- // src/auth/dpop.ts
198
- import { SignJWT, base64url } from "jose";
199
207
  import crypto2 from "crypto";
200
- async function createDPoPProof(privateKey, kid, method, url, nonce, accessToken) {
201
- const payload = {
202
- htm: method,
203
- htu: url,
204
- jti: crypto2.randomUUID()
205
- };
206
- if (nonce) {
207
- payload.nonce = nonce;
208
- }
209
- if (accessToken) {
210
- payload.ath = computeAth(accessToken);
211
- }
212
- const header = {
213
- typ: "dpop+jwt",
214
- alg: "ES256",
215
- jwk: { kid }
216
- };
217
- return new SignJWT(payload).setProtectedHeader(header).setIssuedAt().sign(privateKey);
218
- }
219
- function computeAth(accessToken) {
220
- const hash = crypto2.createHash("sha256").update(accessToken).digest();
221
- return base64url.encode(hash);
222
- }
223
208
 
224
209
  // src/error-translator.ts
225
210
  function translateGovernanceResult(result) {
@@ -249,7 +234,7 @@ function formatDenied(governance) {
249
234
  isError: true
250
235
  };
251
236
  }
252
- function translateHttpError(status, errorCode, _trigger) {
237
+ function translateHttpError(status, errorCode, trigger) {
253
238
  if (status === 401) {
254
239
  if (errorCode === "use_dpop_nonce") {
255
240
  return null;
@@ -267,8 +252,9 @@ function translateHttpError(status, errorCode, _trigger) {
267
252
  );
268
253
  }
269
254
  if (status === 403 && errorCode === "step_up_required") {
255
+ const reason = trigger ? ` (reason: ${trigger})` : "";
270
256
  return makeError(
271
- "Device re-authorization required. The user has been notified but has not yet approved. Try again later."
257
+ `Device re-authorization required${reason}. This action can't proceed until the user re-authorizes this device in the Kojee dashboard.`
272
258
  );
273
259
  }
274
260
  if (status === 429) {
@@ -384,13 +370,7 @@ function makeError(text) {
384
370
  };
385
371
  }
386
372
 
387
- // src/tandem/session-id.ts
388
- import { ulid } from "ulidx";
389
- var MCP_SESSION_ID = ulid();
390
-
391
373
  // src/gateway-client.ts
392
- var STEP_UP_POLL_INTERVAL_MS = 5e3;
393
- var STEP_UP_MAX_TIMEOUT_MS = 3e5;
394
374
  var GatewayClient = class {
395
375
  constructor(brokerUrl, token, privateKey, kid, sessionId) {
396
376
  this.brokerUrl = brokerUrl;
@@ -426,26 +406,35 @@ var GatewayClient = class {
426
406
  * session_id = sha256(token + "proxy").slice(0, 16)
427
407
  */
428
408
  static deriveSessionId(token) {
429
- const hash = crypto3.createHash("sha256").update(token + "proxy").digest("hex");
409
+ const hash = crypto2.createHash("sha256").update(token + "proxy").digest("hex");
430
410
  return hash.slice(0, 16);
431
411
  }
432
412
  /**
433
- * Send a JSON-RPC 2.0 request to the gateway, handling DPoP auth,
434
- * nonce retry, and step-up retry transparently.
413
+ * Send a JSON-RPC 2.0 request to the gateway, handling DPoP auth and
414
+ * nonce retry transparently. A 403 `step_up_required` (deprecated feature,
415
+ * owner ruling 2026-06-10) is no longer polled — it surfaces immediately as
416
+ * a structured tool error via translateHttpError.
417
+ *
418
+ * `signal` (ROUND-3 MAJOR A) is a REAL AbortSignal threaded into the
419
+ * underlying `fetch` option — NOT placed inside `params`/`arguments`. A
420
+ * caller with a per-call timeout budget (e.g. resubscribeMemberships) passes
421
+ * its controller's signal here so a hung backend aborts at the budget instead
422
+ * of hanging forever. Putting the signal in `arguments` (the round-2 bug) both
423
+ * left fetch un-aborted AND serialized a junk `{}` onto the wire body.
435
424
  */
436
- async sendRpc(method, params = {}) {
425
+ async sendRpc(method, params = {}, signal) {
437
426
  const rpcRequest = {
438
427
  jsonrpc: "2.0",
439
428
  id: ++this.requestCounter,
440
429
  method,
441
430
  params
442
431
  };
443
- return this.executeWithRetries(rpcRequest);
432
+ return this.executeWithRetries(rpcRequest, signal);
444
433
  }
445
- async executeWithRetries(rpcRequest) {
434
+ async executeWithRetries(rpcRequest, signal) {
446
435
  let response;
447
436
  try {
448
- response = await this.sendHttpRequest(rpcRequest);
437
+ response = await this.sendHttpRequest(rpcRequest, signal);
449
438
  } catch (err) {
450
439
  return translateNetworkError(err);
451
440
  }
@@ -455,7 +444,7 @@ var GatewayClient = class {
455
444
  if (body?.error === "use_dpop_nonce") {
456
445
  console.error("[gateway] Nonce expired, retrying with fresh nonce...");
457
446
  try {
458
- response = await this.sendHttpRequest(rpcRequest);
447
+ response = await this.sendHttpRequest(rpcRequest, signal);
459
448
  } catch (err) {
460
449
  return translateNetworkError(err);
461
450
  }
@@ -467,9 +456,6 @@ var GatewayClient = class {
467
456
  }
468
457
  if (response.status === 403) {
469
458
  const body = await this.tryParseErrorBody(response);
470
- if (body?.error === "step_up_required") {
471
- return this.handleStepUp(rpcRequest, body.trigger);
472
- }
473
459
  const translated = translateHttpError(403, body?.error, body?.trigger);
474
460
  if (translated) return translated;
475
461
  }
@@ -489,7 +475,7 @@ var GatewayClient = class {
489
475
  const result = rpcResponse.result;
490
476
  return result ?? { content: [{ type: "text", text: "No result" }] };
491
477
  }
492
- async sendHttpRequest(rpcRequest) {
478
+ async sendHttpRequest(rpcRequest, signal) {
493
479
  const proof = await createDPoPProof(
494
480
  this.privateKey,
495
481
  this.kid,
@@ -506,56 +492,13 @@ var GatewayClient = class {
506
492
  DPoP: proof,
507
493
  "Mcp-Session-Id": MCP_SESSION_ID
508
494
  },
509
- body: JSON.stringify(rpcRequest)
495
+ body: JSON.stringify(rpcRequest),
496
+ // ROUND-3 MAJOR A: the caller's AbortSignal rides HERE (a real fetch
497
+ // option), never inside the JSON-RPC body. `undefined` is a valid value
498
+ // for the fetch `signal` option (no abort wired).
499
+ ...signal ? { signal } : {}
510
500
  });
511
501
  }
512
- /**
513
- * Handle step-up retry: poll with backoff until user approves
514
- * or timeout is reached.
515
- */
516
- async handleStepUp(rpcRequest, trigger) {
517
- console.error(
518
- `[gateway] Device re-authorization required (${trigger ?? "unknown"}). Waiting for user approval...`
519
- );
520
- const deadline = Date.now() + STEP_UP_MAX_TIMEOUT_MS;
521
- while (Date.now() < deadline) {
522
- await sleep(STEP_UP_POLL_INTERVAL_MS);
523
- let response;
524
- try {
525
- response = await this.sendHttpRequest(rpcRequest);
526
- } catch {
527
- continue;
528
- }
529
- this.trackNonce(response);
530
- if (response.status === 403) {
531
- const body2 = await this.tryParseErrorBody(response);
532
- if (body2?.error === "step_up_required") {
533
- console.error("[gateway] Still waiting for step-up approval...");
534
- continue;
535
- }
536
- }
537
- if (response.ok) {
538
- const rpcResponse = await response.json();
539
- if (rpcResponse.error) {
540
- return translateJsonRpcError(rpcResponse.error);
541
- }
542
- const result = rpcResponse.result;
543
- return result ?? { content: [{ type: "text", text: "No result" }] };
544
- }
545
- const body = await this.tryParseErrorBody(response);
546
- const translated = translateHttpError(response.status, body?.error);
547
- if (translated) return translated;
548
- }
549
- return {
550
- content: [
551
- {
552
- type: "text",
553
- text: "Device re-authorization was not approved within 5 minutes. Try again later."
554
- }
555
- ],
556
- isError: true
557
- };
558
- }
559
502
  trackNonce(response) {
560
503
  const nonce = response.headers.get("DPoP-Nonce");
561
504
  if (nonce) {
@@ -570,9 +513,6 @@ var GatewayClient = class {
570
513
  }
571
514
  }
572
515
  };
573
- function sleep(ms) {
574
- return new Promise((resolve) => setTimeout(resolve, ms));
575
- }
576
516
 
577
517
  // src/tool-registry.ts
578
518
  var ToolRegistry = class {
@@ -648,16 +588,41 @@ import {
648
588
  ListToolsRequestSchema,
649
589
  CallToolRequestSchema
650
590
  } from "@modelcontextprotocol/sdk/types.js";
651
- function buildChannelInstructions(tandemMembershipCount, eventLogPath) {
652
- const intro = 'Tandem events are delivered to you in three ways depending on which is active in your Claude Code session:\n\n(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. Reply with tandem_send(tandem_id, text, reply_to=<message_id>); read more context with tandem_messages(tandem_id, since=cursor).\n\n';
653
- 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: Monitor(command="tail -n +1 -F ${eventLogPath}", persistent=true, description="kojee Tandem events"). Each appended line will arrive as a separate wake notification. Use the cursor in the line to call tandem_messages(tandem_id, since=cursor-1) for the full message body.
591
+
592
+ // src/version.ts
593
+ import fs2 from "fs";
594
+ import path2 from "path";
595
+ import { fileURLToPath } from "url";
596
+ var FALLBACK_VERSION = "0.0.0-unknown";
597
+ function resolveVersion() {
598
+ try {
599
+ const here = path2.dirname(fileURLToPath(import.meta.url));
600
+ const parsed = JSON.parse(
601
+ fs2.readFileSync(path2.join(here, "..", "package.json"), "utf8")
602
+ );
603
+ return typeof parsed?.version === "string" && parsed.version ? parsed.version : FALLBACK_VERSION;
604
+ } catch (err) {
605
+ process.stderr.write(
606
+ `kojee-mcp: could not resolve version from package.json, falling back to ${FALLBACK_VERSION}: ${String(err)}
607
+ `
608
+ );
609
+ return FALLBACK_VERSION;
610
+ }
611
+ }
612
+ var VERSION = resolveVersion();
613
+
614
+ // src/server.ts
615
+ function buildChannelInstructions(_tandemMembershipCount, eventLogPath) {
616
+ const intro = `Tandem events are delivered to you in three ways depending on which is active in your Claude Code session:
617
+
618
+ (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()}.
619
+
620
+ `;
621
+ 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.)
654
622
 
655
623
  `;
656
624
  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.";
657
625
  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.";
658
- if (tandemMembershipCount === 0) {
659
- return intro + listenSection;
660
- }
661
626
  return intro + monitorSection + listenSection + advice;
662
627
  }
663
628
  function createMcpServer(registry, adapter, tandemMembershipCount = -1, eventLogPath) {
@@ -666,7 +631,7 @@ function createMcpServer(registry, adapter, tandemMembershipCount = -1, eventLog
666
631
  capabilities.experimental = { "claude/channel": {} };
667
632
  }
668
633
  const server = new Server(
669
- { name: "kojee-mcp", version: "0.3.0" },
634
+ { name: "kojee-mcp", version: VERSION },
670
635
  {
671
636
  capabilities,
672
637
  ...adapter.supportsChannels ? { instructions: buildChannelInstructions(tandemMembershipCount, eventLogPath ?? "") } : {}
@@ -695,35 +660,35 @@ async function startMcpServer(server) {
695
660
  }
696
661
 
697
662
  // src/runtime/detect.ts
698
- import childProcess from "child_process";
699
- function detectRuntime(env = process.env) {
663
+ import psList from "ps-list";
664
+ async function detectRuntime(env = process.env) {
700
665
  if (env["KOJEE_RUNTIME"] === "claude-code") return "claude-code";
666
+ if (env["KOJEE_RUNTIME"] === "codex") return "codex";
701
667
  if (env["CLAUDE_CODE_SESSION_ID"]) return "claude-code";
702
- if (parentProcessLooksLikeClaude()) return "claude-code";
668
+ const ancestor = await detectRuntimeFromAncestry();
669
+ if (ancestor) return ancestor;
703
670
  return "unknown";
704
671
  }
705
- function parentProcessLooksLikeClaude() {
706
- if (process.platform === "win32") return false;
672
+ async function detectRuntimeFromAncestry() {
707
673
  try {
674
+ const processes = await psList();
675
+ const byPid = new Map(processes.map((p) => [p.pid, p]));
708
676
  let pid = process.ppid;
709
677
  for (let depth = 0; depth < 5 && pid && pid > 1; depth++) {
710
- const cmd = childProcess.execFileSync("ps", ["-p", String(pid), "-o", "command="], {
711
- encoding: "utf8",
712
- stdio: ["ignore", "pipe", "ignore"]
713
- }).trim();
714
- if (/(^|\/)claude(\.app)?(\/|$|\s|\\)/i.test(cmd) || /Claude Helper/i.test(cmd)) {
715
- return true;
678
+ const row = byPid.get(pid);
679
+ if (!row) return null;
680
+ const haystack = `${row.name} ${row.cmd ?? ""}`;
681
+ if (/(^|\/|\\)claude(\.app|\.exe)?(\/|$|\s|\\)/i.test(haystack) || /Claude Helper/i.test(haystack)) {
682
+ return "claude-code";
683
+ }
684
+ if (/(^|\/|\\)codex(\.exe)?(\/|$|\s|\\)/i.test(haystack)) {
685
+ return "codex";
716
686
  }
717
- const ppidOut = childProcess.execFileSync("ps", ["-o", "ppid=", "-p", String(pid)], {
718
- encoding: "utf8",
719
- stdio: ["ignore", "pipe", "ignore"]
720
- }).trim();
721
- pid = Number.parseInt(ppidOut, 10);
722
- if (!Number.isFinite(pid)) return false;
687
+ pid = row.ppid;
723
688
  }
724
689
  } catch {
725
690
  }
726
- return false;
691
+ return null;
727
692
  }
728
693
 
729
694
  // src/adapters/claude-code.ts
@@ -742,7 +707,7 @@ function formatBody(event) {
742
707
  `[Tandem: ${event.tandem_id}] ${event.from.displayname} (${event.from.principal}) \u2014 ${event.kind}:`,
743
708
  event.content.body,
744
709
  "",
745
- `> reply with tandem_send(tandem_id="${event.tandem_id}", text="...", reply_to="${event.id}")`
710
+ `> ${buildReplyRecipe({ tandem_id: event.tandem_id, message_id: event.id })}`
746
711
  ].join("\n");
747
712
  }
748
713
  var claudeCodeAdapter = {
@@ -762,6 +727,18 @@ var claudeCodeAdapter = {
762
727
  }
763
728
  };
764
729
 
730
+ // src/adapters/codex.ts
731
+ var codexAdapter = {
732
+ runtime: "codex",
733
+ supportsChannels: false,
734
+ // Codex has NO Claude-style channel injection
735
+ formatTandemEvent() {
736
+ throw new Error(
737
+ "codexAdapter.formatTandemEvent() is unreachable \u2014 Codex has no channel injection; server.ts gates this on supportsChannels. Codex receives events via the webhook sink + a model-chosen bounded tandem_listen."
738
+ );
739
+ }
740
+ };
741
+
765
742
  // src/adapters/unknown.ts
766
743
  var unknownAdapter = {
767
744
  runtime: "unknown",
@@ -773,176 +750,44 @@ var unknownAdapter = {
773
750
  }
774
751
  };
775
752
 
776
- // src/tandem/event-stream.ts
777
- async function startEventStream(opts) {
778
- let stopped = false;
779
- let lastCursor = null;
780
- const controller = new AbortController();
781
- void (async function loop() {
782
- let backoffMs = 1e3;
783
- while (!stopped) {
784
- try {
785
- await connectAndConsume(opts, lastCursor, controller, (cursor) => {
786
- lastCursor = cursor;
787
- backoffMs = 1e3;
788
- });
789
- } catch (err) {
790
- if (stopped) return;
791
- console.error("[event-stream] disconnect:", err.message);
792
- }
793
- if (stopped) return;
794
- const jitter = Math.random() * backoffMs;
795
- await sleep2(jitter);
796
- backoffMs = Math.min(backoffMs * 2, 3e4);
797
- }
798
- })();
799
- return () => {
800
- stopped = true;
801
- controller.abort();
802
- };
803
- }
804
- async function connectAndConsume(opts, sinceCursor, controller, onCursor) {
805
- const params = new URLSearchParams();
806
- if (sinceCursor !== null) params.set("since", String(sinceCursor));
807
- const url = `${opts.brokerUrl}/api/v2/tandems/stream${params.toString() ? "?" + params.toString() : ""}`;
808
- const proof = await createDPoPProof(
809
- opts.gateway.getPrivateKey(),
810
- opts.gateway.getKid(),
811
- "GET",
812
- url,
813
- void 0,
814
- opts.token
815
- );
816
- const res = await fetch(url, {
817
- method: "GET",
818
- headers: {
819
- Authorization: `DPoP ${opts.token}`,
820
- DPoP: proof,
821
- "Mcp-Session-Id": MCP_SESSION_ID,
822
- Accept: "text/event-stream"
823
- },
824
- signal: controller.signal
825
- });
826
- if (!res.ok) throw new Error(`SSE connect failed: ${res.status}`);
827
- if (!res.body) throw new Error("SSE response has no body");
828
- await consumeSse(res.body, opts, onCursor);
829
- }
830
- async function consumeSse(body, opts, onCursor) {
831
- const reader = body.getReader();
832
- const decoder = new TextDecoder();
833
- let buffer = "";
834
- while (true) {
835
- const { value, done } = await reader.read();
836
- if (done) return;
837
- buffer += decoder.decode(value, { stream: true });
838
- const events = drainSseEvents(buffer);
839
- buffer = events.remaining;
840
- for (const evt of events.events) {
841
- if (evt.event === "stream_revoked") {
842
- throw new Error("stream_revoked \u2014 reconnect needed");
843
- }
844
- if (evt.event === "heartbeat") continue;
845
- if (evt.event === "message" || evt.event === "state_change") {
846
- try {
847
- const raw = JSON.parse(evt.data);
848
- const parsed = normalizeBackendEvent(raw, evt.event);
849
- onCursor(parsed.cursor);
850
- opts.queue?.push(parsed);
851
- const channel = opts.adapter.formatTandemEvent(parsed);
852
- await opts.server.notification({
853
- method: "notifications/claude/channel",
854
- params: channel
855
- });
856
- opts.queue?.markChannelDelivered(parsed.id);
857
- if (opts.eventLog) {
858
- try {
859
- await opts.eventLog.append(parsed);
860
- opts.queue?.markMonitorDelivered(parsed.id);
861
- } catch (err) {
862
- console.error("[event-stream] event-log append failed:", err);
863
- }
864
- }
865
- } catch (err) {
866
- console.error("[event-stream] failed to handle event:", err);
867
- }
868
- }
869
- }
870
- }
871
- }
872
- function drainSseEvents(input) {
873
- const events = [];
874
- const parts = input.split("\n\n");
875
- const remaining = parts.pop() ?? "";
876
- for (const block of parts) {
877
- let id;
878
- let event = "message";
879
- const dataLines = [];
880
- for (const line of block.split("\n")) {
881
- if (line.startsWith("id: ")) id = line.slice(4);
882
- else if (line.startsWith("event: ")) event = line.slice(7);
883
- else if (line.startsWith("data: ")) dataLines.push(line.slice(6));
884
- }
885
- if (dataLines.length > 0) events.push({ id, event, data: dataLines.join("\n") });
886
- }
887
- return { events, remaining };
888
- }
889
- function sleep2(ms) {
890
- return new Promise((r) => setTimeout(r, ms));
891
- }
892
- function normalizeBackendEvent(raw, sseEventType) {
893
- const obj = raw ?? {};
894
- const maybeFrom = obj["from"];
895
- if (maybeFrom && typeof maybeFrom["principal"] === "string") {
896
- return raw;
897
- }
898
- const sender = obj["sender"] ?? {};
899
- const principal = sender["principal_id"] ?? "";
900
- const agentId = sender["agent_id"];
901
- const displayname = principal ? `principal:${principal.slice(0, 8)}` : "unknown";
902
- const type = sseEventType === "state_change" ? "state_change" : "message";
903
- const kind = obj["kind"] ?? "message";
904
- return {
905
- type,
906
- id: obj["message_id"] ?? obj["id"] ?? "",
907
- tandem_id: obj["tandem_id"] ?? "",
908
- cursor: obj["cursor"] ?? 0,
909
- time: obj["time"] ?? (/* @__PURE__ */ new Date()).toISOString(),
910
- from: {
911
- member_id: "",
912
- principal,
913
- ...agentId ? { agent_id: agentId } : {},
914
- displayname
915
- },
916
- kind,
917
- content: {
918
- body: obj["body"] ?? "",
919
- ...typeof obj["format"] === "string" ? { format: obj["format"] } : {}
920
- },
921
- ...Array.isArray(obj["mentions"]) ? { mentions: obj["mentions"] } : {},
922
- ...obj["reply_to"] !== void 0 ? { reply_to: obj["reply_to"] } : {}
923
- };
924
- }
925
-
926
753
  // src/index.ts
927
- var DEFAULT_KEYSTORE_PATH = path2.join(
928
- process.env["HOME"] ?? "~",
929
- ".kojee",
930
- "keypair.json"
931
- );
754
+ var DEFAULT_KEYSTORE_PATH = path3.join(os2.homedir(), ".kojee", "keypair.json");
932
755
  function isDPoPEnrollmentError(err) {
933
756
  const msg = String(err?.message ?? err ?? "").toLowerCase();
934
757
  if (msg.includes("invalid or expired") && msg.includes("token")) return false;
935
758
  if (msg.includes("generate a new")) return false;
936
759
  return msg.includes("401") || msg.includes("authentication failed") || msg.includes("dpop") || msg.includes("key enrollment") || msg.includes("kid");
937
760
  }
938
- function selectAdapter() {
939
- const runtime = detectRuntime();
761
+ async function selectAdapter() {
762
+ const runtime = await detectRuntime();
940
763
  if (runtime === "claude-code") return claudeCodeAdapter;
764
+ if (runtime === "codex") return codexAdapter;
941
765
  return unknownAdapter;
942
766
  }
767
+ async function listTandemIds(gateway) {
768
+ const result = await gateway.sendRpc("tools/call", { name: "tandem_list", arguments: {} });
769
+ const maybeErr = result;
770
+ if (maybeErr.isError) return null;
771
+ const text = maybeErr.content?.[0]?.text;
772
+ try {
773
+ const parsed = text ? JSON.parse(text) : {};
774
+ const list = Array.isArray(parsed.tandems) ? parsed.tandems : Array.isArray(parsed) ? parsed : null;
775
+ if (!Array.isArray(list)) return [];
776
+ return list.map((t) => {
777
+ if (typeof t === "string") return t;
778
+ const obj = t;
779
+ return obj?.tandem_id ?? obj?.id;
780
+ }).filter((id) => typeof id === "string" && id.length > 0);
781
+ } catch {
782
+ return null;
783
+ }
784
+ }
785
+ function needsWebhookEventStream() {
786
+ return (process.env["KOJEE_WEBHOOK_URL"] ?? "").trim().length > 0;
787
+ }
943
788
  async function startProxy(config) {
944
789
  const keystorePath = config.keystorePath || DEFAULT_KEYSTORE_PATH;
945
- const adapter = selectAdapter();
790
+ const adapter = await selectAdapter();
946
791
  console.error(`[kojee-mcp] Starting proxy for ${config.url} (runtime=${adapter.runtime})`);
947
792
  const { registry, gateway } = await enrollAndDiscover(config, keystorePath);
948
793
  console.error(
@@ -950,22 +795,8 @@ async function startProxy(config) {
950
795
  );
951
796
  let tandemMembershipCount = -1;
952
797
  try {
953
- const result = await gateway.sendRpc("tools/call", { name: "tandem_list", arguments: {} });
954
- const maybeErr = result;
955
- if (!maybeErr.isError) {
956
- const text = maybeErr.content?.[0]?.text;
957
- try {
958
- const parsed = text ? JSON.parse(text) : {};
959
- if (Array.isArray(parsed.tandems)) {
960
- tandemMembershipCount = parsed.tandems.length;
961
- } else if (Array.isArray(parsed)) {
962
- tandemMembershipCount = parsed.length;
963
- } else {
964
- tandemMembershipCount = 0;
965
- }
966
- } catch {
967
- }
968
- }
798
+ const bootIds = await listTandemIds(gateway);
799
+ tandemMembershipCount = bootIds === null ? -1 : bootIds.length;
969
800
  } catch (err) {
970
801
  console.error("[kojee-mcp] tandem_list probe failed:", err.message);
971
802
  }
@@ -973,22 +804,58 @@ async function startProxy(config) {
973
804
  let server;
974
805
  if (adapter.supportsChannels) {
975
806
  const { EventQueue } = await import("./event-queue-5YVJFR3E.js");
976
- const { startHookServer } = await import("./hook-server-43QS7L7P.js");
807
+ const { startHookServer } = await import("./hook-server-QF5JVUHV.js");
977
808
  const {
978
809
  writeDiscoveryByKey,
979
810
  cleanupDiscoveryByKey,
980
811
  sweepStaleDiscovery
981
- } = await import("./session-discovery-WSHLR4OV.js");
982
- const { startEventLog, sweepStaleEventLogs } = await import("./event-log-ETWR6PPY.js");
812
+ } = await import("./session-discovery-FNMJGFPM.js");
813
+ const { startEventLog, sweepStaleEventLogs } = await import("./event-log-RSTM4PLL.js");
814
+ const { resubscribeMemberships } = await import("./resubscribe-SLZNA76S.js");
815
+ const { resolveWebhookConfig } = await import("./webhook-config-5TLLX7RA.js");
816
+ const { createWebhookSink } = await import("./webhook-sink-7OYZBWXA.js");
983
817
  sweepStaleDiscovery();
984
818
  sweepStaleEventLogs();
985
- const ccPid = findClaudeAncestorPid();
819
+ const ccPid = await findClaudeAncestorPid();
986
820
  const projectDir = process.env["CLAUDE_PROJECT_DIR"];
987
821
  const discoveryKey = deriveDiscoveryKey(projectDir, ccPid);
988
822
  const eventLog = startEventLog({ key: discoveryKey });
823
+ const webhookResolution = resolveWebhookConfig();
824
+ if (webhookResolution.error) {
825
+ console.error(`[kojee-mcp] webhook sink ERROR: ${webhookResolution.error}`);
826
+ void eventLog.appendStatus(`status=webhook error="${webhookResolution.error}"`).catch(() => {
827
+ });
828
+ }
829
+ const webhookSink = webhookResolution.enabled && webhookResolution.config ? createWebhookSink(webhookResolution.config, {
830
+ // Route delivery/failure observability to the STATUS sink.
831
+ log: (line) => {
832
+ void eventLog.appendStatus(`status=webhook ${line}`).catch(() => {
833
+ });
834
+ }
835
+ }) : null;
836
+ if (webhookSink) {
837
+ console.error(`[kojee-mcp] webhook sink ENABLED (${webhookSink.configSummary()})`);
838
+ void eventLog.appendStatus(`status=webhook enabled ${webhookSink.configSummary()}`).catch(() => {
839
+ });
840
+ }
989
841
  server = createMcpServer(registry, adapter, tandemMembershipCount, eventLog.path);
990
842
  const queue = new EventQueue();
991
- const hookServer = await startHookServer({ port: 0, queue, adapter });
843
+ let streamHandle = null;
844
+ const hookServer = await startHookServer({
845
+ port: 0,
846
+ queue,
847
+ adapter,
848
+ getStreamState: () => streamHandle ? streamHandle.getState() : {
849
+ connected: false,
850
+ connectedSince: null,
851
+ lastEventAt: null,
852
+ lastHeartbeatAt: null,
853
+ cursors: {},
854
+ reconnectCount: 0,
855
+ // Adaptive: unknown until the watchdog observes ≥2 heartbeats.
856
+ staleAfterMs: null
857
+ }
858
+ });
992
859
  writeDiscoveryByKey(discoveryKey, {
993
860
  schema: 2,
994
861
  discoveryKey,
@@ -1000,7 +867,13 @@ async function startProxy(config) {
1000
867
  pid: process.pid,
1001
868
  port: hookServer.port,
1002
869
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1003
- brokerUrl: config.url
870
+ brokerUrl: config.url,
871
+ eventLogPath: eventLog.path,
872
+ // Stamp the auth mode so `kojee-mcp doctor` renders the pairing check
873
+ // honestly: a token-mode box has no ~/.kojee/config.json by design and
874
+ // must not hard-fail on "paired config: MISSING". Defaults to "paired"
875
+ // for back-compat with callers that don't set it.
876
+ authMode: config.authMode ?? "paired"
1004
877
  });
1005
878
  const cleanupDiscoveryFile = () => cleanupDiscoveryByKey(discoveryKey);
1006
879
  process.on("exit", () => cleanupDiscoveryFile());
@@ -1013,17 +886,45 @@ async function startProxy(config) {
1013
886
  process.exit(0);
1014
887
  });
1015
888
  process.on("exit", () => eventLog.cleanup());
1016
- const cancelStream = await startEventStream({
889
+ streamHandle = await startEventStream({
1017
890
  brokerUrl: config.url,
1018
891
  token: config.token,
1019
892
  gateway,
1020
893
  adapter,
1021
894
  server,
1022
895
  queue,
1023
- eventLog
896
+ eventLog,
897
+ // Generic webhook sink (null unless KOJEE_WEBHOOK_URL + _SECRET are set).
898
+ // Wired LAST in the fan-out, fire-and-forget — can't delay a wake.
899
+ ...webhookSink ? { webhookSink } : {},
900
+ // Resubscribe-on-start (P0 #2): touch all memberships + write a
901
+ // `status=subscribed n=<count>` line on every (re)connect, so a backend
902
+ // restart / scope reset self-heals and the log is never ambiguously
903
+ // empty. See resubscribe.ts for the unverified-touch caveat.
904
+ //
905
+ // MINOR 6: `listTandems` re-fetches the membership list per reconnect (a
906
+ // mid-session join is touched next reconnect, not boot-frozen), each
907
+ // touch is timeout-bounded + run with bounded concurrency, and the whole
908
+ // routine runs concurrently with consumeSse (never blocks first-event
909
+ // delivery). `listTandemIds` may return null (unknown) → treat as empty.
910
+ // MINOR E: a shared debounce cursor damps a connect/drop flap storm — a
911
+ // resubscribe within 30s of the last successful one is skipped.
912
+ onConnected: /* @__PURE__ */ (() => {
913
+ const debounceState = { lastRunAt: 0 };
914
+ return async () => {
915
+ await resubscribeMemberships({
916
+ gateway,
917
+ eventLog,
918
+ listTandems: async () => await listTandemIds(gateway) ?? [],
919
+ debounceState
920
+ });
921
+ };
922
+ })()
1024
923
  });
924
+ const cancelStream = streamHandle;
1025
925
  process.stdin.on("end", () => {
1026
926
  cancelStream();
927
+ void webhookSink?.stop();
1027
928
  cleanupDiscoveryFile();
1028
929
  eventLog.cleanup();
1029
930
  hookServer.stop().finally(() => {
@@ -1031,6 +932,62 @@ async function startProxy(config) {
1031
932
  process.exit(0);
1032
933
  });
1033
934
  });
935
+ } else if (needsWebhookEventStream()) {
936
+ const { startEventLog, sweepStaleEventLogs } = await import("./event-log-RSTM4PLL.js");
937
+ const { resolveWebhookConfig } = await import("./webhook-config-5TLLX7RA.js");
938
+ const { createWebhookSink } = await import("./webhook-sink-7OYZBWXA.js");
939
+ const { resubscribeMemberships } = await import("./resubscribe-SLZNA76S.js");
940
+ sweepStaleEventLogs();
941
+ const ccPid = await findClaudeAncestorPid();
942
+ const projectDir = process.env["CLAUDE_PROJECT_DIR"];
943
+ const discoveryKey = deriveDiscoveryKey(projectDir, ccPid);
944
+ const eventLog = startEventLog({ key: discoveryKey });
945
+ const webhookResolution = resolveWebhookConfig();
946
+ if (webhookResolution.error) {
947
+ console.error(`[kojee-mcp] webhook sink ERROR: ${webhookResolution.error}`);
948
+ void eventLog.appendStatus(`status=webhook error="${webhookResolution.error}"`).catch(() => {
949
+ });
950
+ }
951
+ const webhookSink = webhookResolution.enabled && webhookResolution.config ? createWebhookSink(webhookResolution.config, {
952
+ log: (line) => {
953
+ void eventLog.appendStatus(`status=webhook ${line}`).catch(() => {
954
+ });
955
+ }
956
+ }) : null;
957
+ if (webhookSink) {
958
+ console.error(`[kojee-mcp] webhook sink ENABLED (${webhookSink.configSummary()})`);
959
+ void eventLog.appendStatus(`status=webhook enabled ${webhookSink.configSummary()}`).catch(() => {
960
+ });
961
+ }
962
+ server = createMcpServer(registry, adapter, tandemMembershipCount);
963
+ process.on("exit", () => eventLog.cleanup());
964
+ const streamHandle = await startEventStream({
965
+ brokerUrl: config.url,
966
+ token: config.token,
967
+ gateway,
968
+ adapter,
969
+ server,
970
+ eventLog,
971
+ ...webhookSink ? { webhookSink } : {},
972
+ onConnected: /* @__PURE__ */ (() => {
973
+ const debounceState = { lastRunAt: 0 };
974
+ return async () => {
975
+ await resubscribeMemberships({
976
+ gateway,
977
+ eventLog,
978
+ listTandems: async () => await listTandemIds(gateway) ?? [],
979
+ debounceState
980
+ });
981
+ };
982
+ })()
983
+ });
984
+ process.stdin.on("end", () => {
985
+ streamHandle();
986
+ void webhookSink?.stop();
987
+ eventLog.cleanup();
988
+ console.error("[kojee-mcp] stdin closed, exiting");
989
+ process.exit(0);
990
+ });
1034
991
  } else {
1035
992
  server = createMcpServer(registry, adapter, tandemMembershipCount);
1036
993
  process.stdin.on("end", () => {
@@ -1064,7 +1021,7 @@ async function enrollAndDiscover(config, keystorePath, isRetry = false) {
1064
1021
  "[kojee-mcp] Auth failed, attempting recovery with fresh enrollment..."
1065
1022
  );
1066
1023
  try {
1067
- if (fs2.existsSync(keystorePath)) fs2.unlinkSync(keystorePath);
1024
+ if (fs3.existsSync(keystorePath)) fs3.unlinkSync(keystorePath);
1068
1025
  } catch (unlinkErr) {
1069
1026
  console.error("[kojee-mcp] Could not remove stale keystore:", unlinkErr);
1070
1027
  }
@@ -1074,5 +1031,6 @@ async function enrollAndDiscover(config, keystorePath, isRetry = false) {
1074
1031
 
1075
1032
  export {
1076
1033
  AuthModule,
1034
+ VERSION,
1077
1035
  startProxy
1078
1036
  };