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.
- package/README.md +151 -7
- package/dist/chunk-BJMASMKX.js +41 -0
- package/dist/chunk-E26AHU6J.js +27 -0
- package/dist/chunk-GBOTBYEP.js +34 -0
- package/dist/{chunk-QKAUM3TR.js → chunk-LCFCCWMM.js} +359 -74
- package/dist/chunk-LSUB6QMP.js +75 -0
- package/dist/chunk-QB22PD6T.js +358 -0
- package/dist/chunk-VLZADEFC.js +247 -0
- package/dist/chunk-W6YRLSD4.js +143 -0
- package/dist/cli.js +209 -17
- package/dist/doctor-GILTOH2R.js +222 -0
- package/dist/event-log-R6VW6GAF.js +17 -0
- package/dist/event-queue-5YVJFR3E.js +43 -0
- package/dist/hook-server-QF5JVUHV.js +99 -0
- package/dist/index.d.ts +0 -13
- package/dist/index.js +4 -1
- package/dist/install-D2HIPOMT.js +183 -0
- package/dist/paired-config-RB4SABOS.js +10 -0
- package/dist/resubscribe-SLZNA76S.js +59 -0
- package/dist/session-discovery-QE5TTAPS.js +26 -0
- package/dist/stop-hook-VLQS6QPR.js +118 -0
- package/dist/tail-stream-UZ42UIWO.js +161 -0
- package/dist/user-prompt-submit-hook-C42DPDBO.js +54 -0
- package/package.json +11 -13
|
@@ -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
|
|
3
|
-
import
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
598
|
-
|
|
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 =
|
|
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
|
-
|
|
646
|
-
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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 (
|
|
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
|
+
};
|