kojee-mcp 0.4.0 → 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.
- package/README.md +17 -6
- package/dist/{chunk-36DMIXH7.js → chunk-BJMASMKX.js} +13 -23
- package/dist/chunk-E26AHU6J.js +27 -0
- package/dist/{chunk-E7TE4QZD.js → chunk-GBOTBYEP.js} +2 -1
- package/dist/{chunk-ZGVUM4AG.js → chunk-LCFCCWMM.js} +157 -257
- package/dist/{chunk-WHTH6WBP.js → chunk-LSUB6QMP.js} +3 -0
- package/dist/chunk-QB22PD6T.js +358 -0
- package/dist/chunk-VLZADEFC.js +247 -0
- package/dist/{chunk-VZVGTHGF.js → chunk-W6YRLSD4.js} +2 -1
- package/dist/cli.js +29 -11
- package/dist/doctor-GILTOH2R.js +222 -0
- package/dist/event-log-R6VW6GAF.js +17 -0
- package/dist/{hook-server-43QS7L7P.js → hook-server-QF5JVUHV.js} +28 -0
- package/dist/index.js +4 -2
- package/dist/{install-WV25CRU2.js → install-D2HIPOMT.js} +4 -3
- package/dist/{paired-config-OAR3O3XY.js → paired-config-RB4SABOS.js} +1 -1
- package/dist/resubscribe-SLZNA76S.js +59 -0
- package/dist/{session-discovery-WSHLR4OV.js → session-discovery-QE5TTAPS.js} +1 -1
- package/dist/stop-hook-VLQS6QPR.js +118 -0
- package/dist/tail-stream-UZ42UIWO.js +161 -0
- package/dist/{user-prompt-submit-hook-WSRIJVF4.js → user-prompt-submit-hook-C42DPDBO.js} +4 -4
- package/package.json +9 -7
- package/dist/event-log-ETWR6PPY.js +0 -112
- package/dist/stop-hook-5XU3EQAE.js +0 -76
|
@@ -1,11 +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";
|
|
1
11
|
import {
|
|
2
12
|
deriveDiscoveryKey,
|
|
3
13
|
findClaudeAncestorPid
|
|
4
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-BJMASMKX.js";
|
|
5
15
|
|
|
6
16
|
// src/index.ts
|
|
7
|
-
import
|
|
8
|
-
import
|
|
17
|
+
import fs3 from "fs";
|
|
18
|
+
import os2 from "os";
|
|
19
|
+
import path3 from "path";
|
|
9
20
|
|
|
10
21
|
// src/auth/auth-module.ts
|
|
11
22
|
import { calculateJwkThumbprint } from "jose";
|
|
@@ -14,12 +25,9 @@ import crypto from "crypto";
|
|
|
14
25
|
// src/auth/keystore.ts
|
|
15
26
|
import { importJWK, exportJWK, generateKeyPair } from "jose";
|
|
16
27
|
import fs from "fs";
|
|
28
|
+
import os from "os";
|
|
17
29
|
import path from "path";
|
|
18
|
-
var DEFAULT_PATH = path.join(
|
|
19
|
-
process.env["HOME"] ?? "~",
|
|
20
|
-
".kojee",
|
|
21
|
-
"keypair.json"
|
|
22
|
-
);
|
|
30
|
+
var DEFAULT_PATH = path.join(os.homedir(), ".kojee", "keypair.json");
|
|
23
31
|
async function loadKeystore(keystorePath = DEFAULT_PATH, expectedBrokerUrl) {
|
|
24
32
|
if (!fs.existsSync(keystorePath)) {
|
|
25
33
|
return null;
|
|
@@ -192,34 +200,7 @@ var AuthModule = class {
|
|
|
192
200
|
};
|
|
193
201
|
|
|
194
202
|
// src/gateway-client.ts
|
|
195
|
-
import crypto3 from "crypto";
|
|
196
|
-
|
|
197
|
-
// src/auth/dpop.ts
|
|
198
|
-
import { SignJWT, base64url } from "jose";
|
|
199
203
|
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
204
|
|
|
224
205
|
// src/error-translator.ts
|
|
225
206
|
function translateGovernanceResult(result) {
|
|
@@ -384,10 +365,6 @@ function makeError(text) {
|
|
|
384
365
|
};
|
|
385
366
|
}
|
|
386
367
|
|
|
387
|
-
// src/tandem/session-id.ts
|
|
388
|
-
import { ulid } from "ulidx";
|
|
389
|
-
var MCP_SESSION_ID = ulid();
|
|
390
|
-
|
|
391
368
|
// src/gateway-client.ts
|
|
392
369
|
var STEP_UP_POLL_INTERVAL_MS = 5e3;
|
|
393
370
|
var STEP_UP_MAX_TIMEOUT_MS = 3e5;
|
|
@@ -426,26 +403,33 @@ var GatewayClient = class {
|
|
|
426
403
|
* session_id = sha256(token + "proxy").slice(0, 16)
|
|
427
404
|
*/
|
|
428
405
|
static deriveSessionId(token) {
|
|
429
|
-
const hash =
|
|
406
|
+
const hash = crypto2.createHash("sha256").update(token + "proxy").digest("hex");
|
|
430
407
|
return hash.slice(0, 16);
|
|
431
408
|
}
|
|
432
409
|
/**
|
|
433
410
|
* Send a JSON-RPC 2.0 request to the gateway, handling DPoP auth,
|
|
434
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.
|
|
435
419
|
*/
|
|
436
|
-
async sendRpc(method, params = {}) {
|
|
420
|
+
async sendRpc(method, params = {}, signal) {
|
|
437
421
|
const rpcRequest = {
|
|
438
422
|
jsonrpc: "2.0",
|
|
439
423
|
id: ++this.requestCounter,
|
|
440
424
|
method,
|
|
441
425
|
params
|
|
442
426
|
};
|
|
443
|
-
return this.executeWithRetries(rpcRequest);
|
|
427
|
+
return this.executeWithRetries(rpcRequest, signal);
|
|
444
428
|
}
|
|
445
|
-
async executeWithRetries(rpcRequest) {
|
|
429
|
+
async executeWithRetries(rpcRequest, signal) {
|
|
446
430
|
let response;
|
|
447
431
|
try {
|
|
448
|
-
response = await this.sendHttpRequest(rpcRequest);
|
|
432
|
+
response = await this.sendHttpRequest(rpcRequest, signal);
|
|
449
433
|
} catch (err) {
|
|
450
434
|
return translateNetworkError(err);
|
|
451
435
|
}
|
|
@@ -455,7 +439,7 @@ var GatewayClient = class {
|
|
|
455
439
|
if (body?.error === "use_dpop_nonce") {
|
|
456
440
|
console.error("[gateway] Nonce expired, retrying with fresh nonce...");
|
|
457
441
|
try {
|
|
458
|
-
response = await this.sendHttpRequest(rpcRequest);
|
|
442
|
+
response = await this.sendHttpRequest(rpcRequest, signal);
|
|
459
443
|
} catch (err) {
|
|
460
444
|
return translateNetworkError(err);
|
|
461
445
|
}
|
|
@@ -468,7 +452,7 @@ var GatewayClient = class {
|
|
|
468
452
|
if (response.status === 403) {
|
|
469
453
|
const body = await this.tryParseErrorBody(response);
|
|
470
454
|
if (body?.error === "step_up_required") {
|
|
471
|
-
return this.handleStepUp(rpcRequest, body.trigger);
|
|
455
|
+
return this.handleStepUp(rpcRequest, body.trigger, signal);
|
|
472
456
|
}
|
|
473
457
|
const translated = translateHttpError(403, body?.error, body?.trigger);
|
|
474
458
|
if (translated) return translated;
|
|
@@ -489,7 +473,7 @@ var GatewayClient = class {
|
|
|
489
473
|
const result = rpcResponse.result;
|
|
490
474
|
return result ?? { content: [{ type: "text", text: "No result" }] };
|
|
491
475
|
}
|
|
492
|
-
async sendHttpRequest(rpcRequest) {
|
|
476
|
+
async sendHttpRequest(rpcRequest, signal) {
|
|
493
477
|
const proof = await createDPoPProof(
|
|
494
478
|
this.privateKey,
|
|
495
479
|
this.kid,
|
|
@@ -506,14 +490,18 @@ var GatewayClient = class {
|
|
|
506
490
|
DPoP: proof,
|
|
507
491
|
"Mcp-Session-Id": MCP_SESSION_ID
|
|
508
492
|
},
|
|
509
|
-
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 } : {}
|
|
510
498
|
});
|
|
511
499
|
}
|
|
512
500
|
/**
|
|
513
501
|
* Handle step-up retry: poll with backoff until user approves
|
|
514
502
|
* or timeout is reached.
|
|
515
503
|
*/
|
|
516
|
-
async handleStepUp(rpcRequest, trigger) {
|
|
504
|
+
async handleStepUp(rpcRequest, trigger, signal) {
|
|
517
505
|
console.error(
|
|
518
506
|
`[gateway] Device re-authorization required (${trigger ?? "unknown"}). Waiting for user approval...`
|
|
519
507
|
);
|
|
@@ -522,7 +510,7 @@ var GatewayClient = class {
|
|
|
522
510
|
await sleep(STEP_UP_POLL_INTERVAL_MS);
|
|
523
511
|
let response;
|
|
524
512
|
try {
|
|
525
|
-
response = await this.sendHttpRequest(rpcRequest);
|
|
513
|
+
response = await this.sendHttpRequest(rpcRequest, signal);
|
|
526
514
|
} catch {
|
|
527
515
|
continue;
|
|
528
516
|
}
|
|
@@ -648,16 +636,41 @@ import {
|
|
|
648
636
|
ListToolsRequestSchema,
|
|
649
637
|
CallToolRequestSchema
|
|
650
638
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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.)
|
|
654
670
|
|
|
655
671
|
`;
|
|
656
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.";
|
|
657
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.";
|
|
658
|
-
if (tandemMembershipCount === 0) {
|
|
659
|
-
return intro + listenSection;
|
|
660
|
-
}
|
|
661
674
|
return intro + monitorSection + listenSection + advice;
|
|
662
675
|
}
|
|
663
676
|
function createMcpServer(registry, adapter, tandemMembershipCount = -1, eventLogPath) {
|
|
@@ -666,7 +679,7 @@ function createMcpServer(registry, adapter, tandemMembershipCount = -1, eventLog
|
|
|
666
679
|
capabilities.experimental = { "claude/channel": {} };
|
|
667
680
|
}
|
|
668
681
|
const server = new Server(
|
|
669
|
-
{ name: "kojee-mcp", version:
|
|
682
|
+
{ name: "kojee-mcp", version: VERSION },
|
|
670
683
|
{
|
|
671
684
|
capabilities,
|
|
672
685
|
...adapter.supportsChannels ? { instructions: buildChannelInstructions(tandemMembershipCount, eventLogPath ?? "") } : {}
|
|
@@ -695,31 +708,26 @@ async function startMcpServer(server) {
|
|
|
695
708
|
}
|
|
696
709
|
|
|
697
710
|
// src/runtime/detect.ts
|
|
698
|
-
import
|
|
699
|
-
function detectRuntime(env = process.env) {
|
|
711
|
+
import psList from "ps-list";
|
|
712
|
+
async function detectRuntime(env = process.env) {
|
|
700
713
|
if (env["KOJEE_RUNTIME"] === "claude-code") return "claude-code";
|
|
701
714
|
if (env["CLAUDE_CODE_SESSION_ID"]) return "claude-code";
|
|
702
|
-
if (parentProcessLooksLikeClaude()) return "claude-code";
|
|
715
|
+
if (await parentProcessLooksLikeClaude()) return "claude-code";
|
|
703
716
|
return "unknown";
|
|
704
717
|
}
|
|
705
|
-
function parentProcessLooksLikeClaude() {
|
|
706
|
-
if (process.platform === "win32") return false;
|
|
718
|
+
async function parentProcessLooksLikeClaude() {
|
|
707
719
|
try {
|
|
720
|
+
const processes = await psList();
|
|
721
|
+
const byPid = new Map(processes.map((p) => [p.pid, p]));
|
|
708
722
|
let pid = process.ppid;
|
|
709
723
|
for (let depth = 0; depth < 5 && pid && pid > 1; depth++) {
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
if (/(^|\/)claude(\.app)?(\/|$|\s|\\)/i.test(cmd) || /Claude Helper/i.test(cmd)) {
|
|
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)) {
|
|
715
728
|
return true;
|
|
716
729
|
}
|
|
717
|
-
|
|
718
|
-
encoding: "utf8",
|
|
719
|
-
stdio: ["ignore", "pipe", "ignore"]
|
|
720
|
-
}).trim();
|
|
721
|
-
pid = Number.parseInt(ppidOut, 10);
|
|
722
|
-
if (!Number.isFinite(pid)) return false;
|
|
730
|
+
pid = row.ppid;
|
|
723
731
|
}
|
|
724
732
|
} catch {
|
|
725
733
|
}
|
|
@@ -742,7 +750,7 @@ function formatBody(event) {
|
|
|
742
750
|
`[Tandem: ${event.tandem_id}] ${event.from.displayname} (${event.from.principal}) \u2014 ${event.kind}:`,
|
|
743
751
|
event.content.body,
|
|
744
752
|
"",
|
|
745
|
-
`>
|
|
753
|
+
`> ${buildReplyRecipe({ tandem_id: event.tandem_id, message_id: event.id })}`
|
|
746
754
|
].join("\n");
|
|
747
755
|
}
|
|
748
756
|
var claudeCodeAdapter = {
|
|
@@ -773,176 +781,40 @@ var unknownAdapter = {
|
|
|
773
781
|
}
|
|
774
782
|
};
|
|
775
783
|
|
|
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
784
|
// src/index.ts
|
|
927
|
-
var DEFAULT_KEYSTORE_PATH =
|
|
928
|
-
process.env["HOME"] ?? "~",
|
|
929
|
-
".kojee",
|
|
930
|
-
"keypair.json"
|
|
931
|
-
);
|
|
785
|
+
var DEFAULT_KEYSTORE_PATH = path3.join(os2.homedir(), ".kojee", "keypair.json");
|
|
932
786
|
function isDPoPEnrollmentError(err) {
|
|
933
787
|
const msg = String(err?.message ?? err ?? "").toLowerCase();
|
|
934
788
|
if (msg.includes("invalid or expired") && msg.includes("token")) return false;
|
|
935
789
|
if (msg.includes("generate a new")) return false;
|
|
936
790
|
return msg.includes("401") || msg.includes("authentication failed") || msg.includes("dpop") || msg.includes("key enrollment") || msg.includes("kid");
|
|
937
791
|
}
|
|
938
|
-
function selectAdapter() {
|
|
939
|
-
const runtime = detectRuntime();
|
|
792
|
+
async function selectAdapter() {
|
|
793
|
+
const runtime = await detectRuntime();
|
|
940
794
|
if (runtime === "claude-code") return claudeCodeAdapter;
|
|
941
795
|
return unknownAdapter;
|
|
942
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
|
+
}
|
|
943
815
|
async function startProxy(config) {
|
|
944
816
|
const keystorePath = config.keystorePath || DEFAULT_KEYSTORE_PATH;
|
|
945
|
-
const adapter = selectAdapter();
|
|
817
|
+
const adapter = await selectAdapter();
|
|
946
818
|
console.error(`[kojee-mcp] Starting proxy for ${config.url} (runtime=${adapter.runtime})`);
|
|
947
819
|
const { registry, gateway } = await enrollAndDiscover(config, keystorePath);
|
|
948
820
|
console.error(
|
|
@@ -950,22 +822,8 @@ async function startProxy(config) {
|
|
|
950
822
|
);
|
|
951
823
|
let tandemMembershipCount = -1;
|
|
952
824
|
try {
|
|
953
|
-
const
|
|
954
|
-
|
|
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
|
-
}
|
|
825
|
+
const bootIds = await listTandemIds(gateway);
|
|
826
|
+
tandemMembershipCount = bootIds === null ? -1 : bootIds.length;
|
|
969
827
|
} catch (err) {
|
|
970
828
|
console.error("[kojee-mcp] tandem_list probe failed:", err.message);
|
|
971
829
|
}
|
|
@@ -973,22 +831,38 @@ async function startProxy(config) {
|
|
|
973
831
|
let server;
|
|
974
832
|
if (adapter.supportsChannels) {
|
|
975
833
|
const { EventQueue } = await import("./event-queue-5YVJFR3E.js");
|
|
976
|
-
const { startHookServer } = await import("./hook-server-
|
|
834
|
+
const { startHookServer } = await import("./hook-server-QF5JVUHV.js");
|
|
977
835
|
const {
|
|
978
836
|
writeDiscoveryByKey,
|
|
979
837
|
cleanupDiscoveryByKey,
|
|
980
838
|
sweepStaleDiscovery
|
|
981
|
-
} = await import("./session-discovery-
|
|
982
|
-
const { startEventLog, sweepStaleEventLogs } = await import("./event-log-
|
|
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");
|
|
983
842
|
sweepStaleDiscovery();
|
|
984
843
|
sweepStaleEventLogs();
|
|
985
|
-
const ccPid = findClaudeAncestorPid();
|
|
844
|
+
const ccPid = await findClaudeAncestorPid();
|
|
986
845
|
const projectDir = process.env["CLAUDE_PROJECT_DIR"];
|
|
987
846
|
const discoveryKey = deriveDiscoveryKey(projectDir, ccPid);
|
|
988
847
|
const eventLog = startEventLog({ key: discoveryKey });
|
|
989
848
|
server = createMcpServer(registry, adapter, tandemMembershipCount, eventLog.path);
|
|
990
849
|
const queue = new EventQueue();
|
|
991
|
-
|
|
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
|
+
});
|
|
992
866
|
writeDiscoveryByKey(discoveryKey, {
|
|
993
867
|
schema: 2,
|
|
994
868
|
discoveryKey,
|
|
@@ -1000,7 +874,8 @@ async function startProxy(config) {
|
|
|
1000
874
|
pid: process.pid,
|
|
1001
875
|
port: hookServer.port,
|
|
1002
876
|
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1003
|
-
brokerUrl: config.url
|
|
877
|
+
brokerUrl: config.url,
|
|
878
|
+
eventLogPath: eventLog.path
|
|
1004
879
|
});
|
|
1005
880
|
const cleanupDiscoveryFile = () => cleanupDiscoveryByKey(discoveryKey);
|
|
1006
881
|
process.on("exit", () => cleanupDiscoveryFile());
|
|
@@ -1013,15 +888,39 @@ async function startProxy(config) {
|
|
|
1013
888
|
process.exit(0);
|
|
1014
889
|
});
|
|
1015
890
|
process.on("exit", () => eventLog.cleanup());
|
|
1016
|
-
|
|
891
|
+
streamHandle = await startEventStream({
|
|
1017
892
|
brokerUrl: config.url,
|
|
1018
893
|
token: config.token,
|
|
1019
894
|
gateway,
|
|
1020
895
|
adapter,
|
|
1021
896
|
server,
|
|
1022
897
|
queue,
|
|
1023
|
-
eventLog
|
|
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
|
+
})()
|
|
1024
922
|
});
|
|
923
|
+
const cancelStream = streamHandle;
|
|
1025
924
|
process.stdin.on("end", () => {
|
|
1026
925
|
cancelStream();
|
|
1027
926
|
cleanupDiscoveryFile();
|
|
@@ -1064,7 +963,7 @@ async function enrollAndDiscover(config, keystorePath, isRetry = false) {
|
|
|
1064
963
|
"[kojee-mcp] Auth failed, attempting recovery with fresh enrollment..."
|
|
1065
964
|
);
|
|
1066
965
|
try {
|
|
1067
|
-
if (
|
|
966
|
+
if (fs3.existsSync(keystorePath)) fs3.unlinkSync(keystorePath);
|
|
1068
967
|
} catch (unlinkErr) {
|
|
1069
968
|
console.error("[kojee-mcp] Could not remove stale keystore:", unlinkErr);
|
|
1070
969
|
}
|
|
@@ -1074,5 +973,6 @@ async function enrollAndDiscover(config, keystorePath, isRetry = false) {
|
|
|
1074
973
|
|
|
1075
974
|
export {
|
|
1076
975
|
AuthModule,
|
|
976
|
+
VERSION,
|
|
1077
977
|
startProxy
|
|
1078
978
|
};
|
|
@@ -5,6 +5,7 @@ var NULL_RESULT = {
|
|
|
5
5
|
sessionId: null,
|
|
6
6
|
transcriptPath: null,
|
|
7
7
|
hookEventName: null,
|
|
8
|
+
stopHookActive: false,
|
|
8
9
|
raw: ""
|
|
9
10
|
};
|
|
10
11
|
async function readHookStdin() {
|
|
@@ -21,6 +22,7 @@ async function readHookStdin() {
|
|
|
21
22
|
sessionId: stringOrNull(parsed["session_id"]),
|
|
22
23
|
transcriptPath: stringOrNull(parsed["transcript_path"]),
|
|
23
24
|
hookEventName: stringOrNull(parsed["hook_event_name"]),
|
|
25
|
+
stopHookActive: parsed["stop_hook_active"] === true,
|
|
24
26
|
raw
|
|
25
27
|
};
|
|
26
28
|
} catch {
|
|
@@ -28,6 +30,7 @@ async function readHookStdin() {
|
|
|
28
30
|
sessionId: null,
|
|
29
31
|
transcriptPath: null,
|
|
30
32
|
hookEventName: null,
|
|
33
|
+
stopHookActive: false,
|
|
31
34
|
raw
|
|
32
35
|
};
|
|
33
36
|
}
|