kojee-mcp 0.5.11 → 0.5.13
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 +24 -0
- package/dist/ancestry-ONFBQEP5.js +8 -0
- package/dist/{chunk-LDZXU3DW.js → chunk-2OLXXOT3.js} +1 -0
- package/dist/{codex-stop-hook-SWA53ECG.js → chunk-35XBRG3V.js} +4 -3
- package/dist/chunk-3H3TL34J.js +401 -0
- package/dist/{chunk-MKDMAAMN.js → chunk-74XFVX6Z.js} +16 -4
- package/dist/chunk-CM3EKMDD.js +116 -0
- package/dist/{chunk-DS26OORG.js → chunk-CO73VGWM.js} +41 -23
- package/dist/{chunk-HIZ4NDWN.js → chunk-DXJ6QLSJ.js} +22 -2
- package/dist/{chunk-HSR3GXCL.js → chunk-IMOEZ4NJ.js} +83 -6
- package/dist/{chunk-2MIISF2W.js → chunk-NR4Y54OL.js} +14 -1
- package/dist/chunk-XLKGPGZT.js +0 -0
- package/dist/chunk-XXFVWP6H.js +44 -0
- package/dist/cli.js +17 -15
- package/dist/codex-stop-hook-VY7DOMAG.js +16 -0
- package/dist/{doctor-XK335W7B.js → doctor-FVTALRQD.js} +110 -15
- package/dist/{event-log-B27VVEMK.js → event-log-VZD7NKYX.js} +1 -1
- package/dist/event-stream-XX5EZ6HN.js +19 -0
- package/dist/{gateway-client-93P1E0CZ.d.ts → gateway-client-C6yx1mfM.d.ts} +6 -1
- package/dist/{hook-server-37E2LUKJ.js → hook-server-T2Z444OV.js} +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +8 -7
- package/dist/lib.d.ts +224 -3
- package/dist/lib.js +16 -11
- package/dist/{parent-watchdog-RZLHYP7T.js → parent-watchdog-TLU355FB.js} +1 -1
- package/dist/registry-TGALQP6M.js +348 -0
- package/dist/{send-cli-CN5EX7PO.js → send-cli-RH7D4JDP.js} +18 -10
- package/dist/server-LBVEDIXP.js +14 -0
- package/dist/{stop-hook-GEJF47SN.js → stop-hook-CUVDKXP7.js} +7 -6
- package/dist/{tail-stream-JNR4WFW3.js → tail-stream-5DAVRQYK.js} +4 -3
- package/dist/{user-prompt-submit-hook-DGRRFHOB.js → user-prompt-submit-hook-PMBUPKUV.js} +5 -5
- package/dist/{webhook-sink-NWGCUDGY.js → webhook-sink-N6AUTFL3.js} +1 -1
- package/package.json +1 -1
- package/dist/chunk-D42PZX2I.js +0 -784
- package/dist/{chunk-BJMASMKX.js → chunk-VHKPWUX7.js} +0 -0
- package/dist/{doctor-codex-SMROUYGV.js → doctor-codex-PA3WO6LR.js} +1 -1
- package/dist/{wizard-PLGHYCT3.js → wizard-L4MYRLJI.js} +11 -11
package/README.md
CHANGED
|
@@ -240,6 +240,30 @@ Run bare (no `--token`) and the proxy uses **paired** credentials from
|
|
|
240
240
|
| **openclaw** | **native channel plugin** (not MCP) | OpenClaw's own routing/sessions wake the agent on each Tandem event |
|
|
241
241
|
| **hermes** | **native channel plugin** (sidecar daemon + webhook) | Hermes gateway wakes the agent like a Telegram DM |
|
|
242
242
|
|
|
243
|
+
## Wake-delivery architecture (`src/delivery/`)
|
|
244
|
+
|
|
245
|
+
Wake delivery is **pluggable, one module per runtime** behind a single interface, so **adding a runtime = write one `src/delivery/<runtime>.ts` + a line in the registry** — no edits to the SSE consume loop.
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
// src/delivery/types.ts
|
|
249
|
+
interface WakeDelivery {
|
|
250
|
+
readonly name: string;
|
|
251
|
+
start(ctx: WakeDeliveryContext): Promise<StartedDelivery>; // open log / bind port / build sinks + stream
|
|
252
|
+
deliver(event: TandemEvent): Promise<void>; // per-event fan-out; each sink isolated, never throws into consumeSse
|
|
253
|
+
stop(): Promise<void>; // idempotent teardown
|
|
254
|
+
health?(): DeliveryHealth; // surfaced by `kojee-mcp doctor`
|
|
255
|
+
}
|
|
256
|
+
// ctx = { instanceKey, runtime, eventLogDir, config, log } — framework-provided
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
- **Framework** — `types.ts` (interface), `registry.ts` (`runtime → WakeDelivery`, reproducing the channels / webhook-only / none gating); `index.ts` builds the runtime's delivery from the registry and `consumeSse` calls `deliver()` generically (cursor-tracking + wake-filter run first).
|
|
260
|
+
- **Shared primitives** — `lib/event-log.ts` (Monitor substrate), `lib/webhook-sink.ts` (HMAC-signed POST), `lib/channel.ts` (CC channel render), `lib/fanout.ts` (the per-event sink fan-out).
|
|
261
|
+
- **Per-runtime modules** — `claude-code.ts` (channel + event-log/Monitor), `hermes.ts` (HTTP webhook), `codex.ts` (webhook delegate; the Codex Stop-hook stays a CLI subcommand, **not** a sink), `openclaw.ts` (host-supplied sentinel — OpenClaw runs the proxy in-process and injects its own `WakeDelivery` via `kojee-mcp/lib`).
|
|
262
|
+
|
|
263
|
+
**Session-faithful wake path.** The event log is keyed on a **stable instance key** (the Claude Code `session_id`, read from the parent env; else `KOJEE_INSTANCE`, else a project-dir hash). Because that key is stable across a window restart / crash / `--resume` (the pid is not), a persistent **Monitor keeps tailing a live log across a restart** instead of being stranded on a dead one. A startup **reaper** removes dead proxies' logs + discovery files (never a live sibling's — liveness-gated), and `kojee-mcp doctor` warns on a stale Monitor or a sibling proxy holding the webhook env. *(Degraded mode: where no session id is readable — e.g. Windows — set `KOJEE_INSTANCE=<unique-per-window>` so concurrent windows in one project dir don't share an event log.)*
|
|
264
|
+
|
|
265
|
+
**Adding a runtime:** implement `WakeDelivery` in `src/delivery/<runtime>.ts` (compose the `lib/` primitives), register it in `src/delivery/registry.ts`, done.
|
|
266
|
+
|
|
243
267
|
## Native gateway runtimes (OpenClaw + Hermes)
|
|
244
268
|
|
|
245
269
|
On OpenClaw and Hermes, Tandem is a **native channel** — peer to Telegram/Discord
|
|
@@ -62,6 +62,7 @@ function translateHttpError(status, errorCode, trigger) {
|
|
|
62
62
|
return null;
|
|
63
63
|
}
|
|
64
64
|
var TANDEM_ERROR_MESSAGES = {
|
|
65
|
+
[-32001]: () => "You aren't an active member of that Tandem (the seat may have been reaped). Run tandem_join to reactivate it, then retry.",
|
|
65
66
|
[-32003]: () => "This Tandem is hardened to owner-only membership; you can't join.",
|
|
66
67
|
[-32004]: () => "You aren't a member of that Tandem. Use tandem_join(join_link) first.",
|
|
67
68
|
[-32006]: (data) => {
|
|
@@ -63,10 +63,11 @@ async function runCodexStopHook() {
|
|
|
63
63
|
process.stdout.write(out);
|
|
64
64
|
}
|
|
65
65
|
var CODEX_PEEK_BUDGET_MS = CODEX_PEEK_MS;
|
|
66
|
+
|
|
66
67
|
export {
|
|
67
|
-
CODEX_PEEK_BUDGET_MS,
|
|
68
|
-
codexPendingMarkerPath,
|
|
69
68
|
decideCodexStopHook,
|
|
69
|
+
codexPendingMarkerPath,
|
|
70
70
|
defaultPeekPending,
|
|
71
|
-
runCodexStopHook
|
|
71
|
+
runCodexStopHook,
|
|
72
|
+
CODEX_PEEK_BUDGET_MS
|
|
72
73
|
};
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import {
|
|
2
|
+
claudeCodeAdapter
|
|
3
|
+
} from "./chunk-XXFVWP6H.js";
|
|
4
|
+
import {
|
|
5
|
+
GatewayClient,
|
|
6
|
+
applyStableSessionId
|
|
7
|
+
} from "./chunk-IMOEZ4NJ.js";
|
|
8
|
+
import {
|
|
9
|
+
AuthModule
|
|
10
|
+
} from "./chunk-JXMVZEQ7.js";
|
|
11
|
+
import {
|
|
12
|
+
secureDir,
|
|
13
|
+
secureFile
|
|
14
|
+
} from "./chunk-BLEGIR35.js";
|
|
15
|
+
import {
|
|
16
|
+
createMcpServer,
|
|
17
|
+
startMcpServer
|
|
18
|
+
} from "./chunk-CM3EKMDD.js";
|
|
19
|
+
import {
|
|
20
|
+
findClaudeAncestorPid
|
|
21
|
+
} from "./chunk-VHKPWUX7.js";
|
|
22
|
+
import {
|
|
23
|
+
parseTandemsConfig
|
|
24
|
+
} from "./chunk-YKW54DKF.js";
|
|
25
|
+
|
|
26
|
+
// src/index.ts
|
|
27
|
+
import fs2 from "fs";
|
|
28
|
+
import os2 from "os";
|
|
29
|
+
import path2 from "path";
|
|
30
|
+
|
|
31
|
+
// src/tool-registry.ts
|
|
32
|
+
var ToolRegistry = class {
|
|
33
|
+
constructor(gateway) {
|
|
34
|
+
this.gateway = gateway;
|
|
35
|
+
}
|
|
36
|
+
gateway;
|
|
37
|
+
/** Flat map: tool name → full tool definition */
|
|
38
|
+
tools = /* @__PURE__ */ new Map();
|
|
39
|
+
/**
|
|
40
|
+
* Fetch all tools with full schemas from the gateway in a single RPC call.
|
|
41
|
+
*/
|
|
42
|
+
async discoverTools() {
|
|
43
|
+
console.error("[tools] Fetching tools with full schemas from gateway...");
|
|
44
|
+
const result = await this.gateway.sendRpc("tools/list", {
|
|
45
|
+
include_schema: true
|
|
46
|
+
});
|
|
47
|
+
const maybeError = result;
|
|
48
|
+
if (maybeError.isError) {
|
|
49
|
+
const msg = maybeError.content?.[0]?.text ?? "unknown error";
|
|
50
|
+
throw new Error(`Gateway rejected tools/list: ${msg}`);
|
|
51
|
+
}
|
|
52
|
+
const toolList = result?.tools;
|
|
53
|
+
if (!toolList || !Array.isArray(toolList)) {
|
|
54
|
+
console.error("[tools] No tools returned from gateway");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
for (const tool of toolList) {
|
|
58
|
+
if (this.tools.has(tool.name)) {
|
|
59
|
+
console.error(`[tools] Warning: duplicate tool name "${tool.name}" \u2014 overwriting`);
|
|
60
|
+
}
|
|
61
|
+
this.tools.set(tool.name, tool);
|
|
62
|
+
}
|
|
63
|
+
console.error(`[tools] Registered ${this.tools.size} tools from gateway`);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Return all registered tools for the MCP ListTools response.
|
|
67
|
+
*/
|
|
68
|
+
getAllTools() {
|
|
69
|
+
return Array.from(this.tools.values());
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Call a tool through the gateway.
|
|
73
|
+
*/
|
|
74
|
+
async callTool(name, args) {
|
|
75
|
+
if (!this.tools.has(name)) {
|
|
76
|
+
const available = Array.from(this.tools.keys()).slice(0, 10).join(", ");
|
|
77
|
+
return {
|
|
78
|
+
content: [
|
|
79
|
+
{
|
|
80
|
+
type: "text",
|
|
81
|
+
text: `Tool '${name}' not found. Some available tools: ${available}${this.tools.size > 10 ? ", ..." : ""}`
|
|
82
|
+
}
|
|
83
|
+
],
|
|
84
|
+
isError: true
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return this.gateway.sendRpc("tools/call", {
|
|
88
|
+
name,
|
|
89
|
+
arguments: args
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/** Total number of registered tools. */
|
|
93
|
+
get toolCount() {
|
|
94
|
+
return this.tools.size;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// src/runtime/detect.ts
|
|
99
|
+
import psList from "ps-list";
|
|
100
|
+
async function detectRuntime(env = process.env) {
|
|
101
|
+
if (env["KOJEE_RUNTIME"] === "claude-code") return "claude-code";
|
|
102
|
+
if (env["KOJEE_RUNTIME"] === "codex") return "codex";
|
|
103
|
+
if (env["CLAUDE_CODE_SESSION_ID"]) return "claude-code";
|
|
104
|
+
const ancestor = await detectRuntimeFromAncestry();
|
|
105
|
+
if (ancestor) return ancestor;
|
|
106
|
+
return "unknown";
|
|
107
|
+
}
|
|
108
|
+
async function detectRuntimeFromAncestry() {
|
|
109
|
+
try {
|
|
110
|
+
const processes = await psList();
|
|
111
|
+
const byPid = new Map(processes.map((p) => [p.pid, p]));
|
|
112
|
+
let pid = process.ppid;
|
|
113
|
+
for (let depth = 0; depth < 5 && pid && pid > 1; depth++) {
|
|
114
|
+
const row = byPid.get(pid);
|
|
115
|
+
if (!row) return null;
|
|
116
|
+
const haystack = `${row.name} ${row.cmd ?? ""}`;
|
|
117
|
+
if (/(^|\/|\\)claude(\.app|\.exe)?(\/|$|\s|\\)/i.test(haystack) || /Claude Helper/i.test(haystack)) {
|
|
118
|
+
return "claude-code";
|
|
119
|
+
}
|
|
120
|
+
if (/(^|\/|\\)codex(\.exe)?(\/|$|\s|\\)/i.test(haystack)) {
|
|
121
|
+
return "codex";
|
|
122
|
+
}
|
|
123
|
+
pid = row.ppid;
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/adapters/codex.ts
|
|
131
|
+
var codexAdapter = {
|
|
132
|
+
runtime: "codex",
|
|
133
|
+
supportsChannels: false,
|
|
134
|
+
// Codex has NO Claude-style channel injection
|
|
135
|
+
formatTandemEvent() {
|
|
136
|
+
throw new Error(
|
|
137
|
+
"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."
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// src/adapters/unknown.ts
|
|
143
|
+
var unknownAdapter = {
|
|
144
|
+
runtime: "unknown",
|
|
145
|
+
supportsChannels: false,
|
|
146
|
+
formatTandemEvent() {
|
|
147
|
+
throw new Error(
|
|
148
|
+
"unknownAdapter.formatTandemEvent() is unreachable \u2014 caller should gate on supportsChannels"
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// src/tandem/room-memory.ts
|
|
154
|
+
import fs from "fs";
|
|
155
|
+
import os from "os";
|
|
156
|
+
import path from "path";
|
|
157
|
+
function defaultKojeeDir() {
|
|
158
|
+
return path.join(os.homedir(), ".kojee");
|
|
159
|
+
}
|
|
160
|
+
function seatedRoomsPath(key, dir = defaultKojeeDir()) {
|
|
161
|
+
return path.join(dir, `seated-rooms-cc-${key}.json`);
|
|
162
|
+
}
|
|
163
|
+
function readSeatedRooms(key, dir = defaultKojeeDir()) {
|
|
164
|
+
let raw;
|
|
165
|
+
try {
|
|
166
|
+
raw = fs.readFileSync(seatedRoomsPath(key, dir), "utf8");
|
|
167
|
+
} catch {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const parsed = JSON.parse(raw);
|
|
172
|
+
return Array.isArray(parsed.rooms) ? parsed.rooms.filter((r) => typeof r === "string") : [];
|
|
173
|
+
} catch {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function hasSeatedRoomsFile(key, dir = defaultKojeeDir()) {
|
|
178
|
+
return fs.existsSync(seatedRoomsPath(key, dir));
|
|
179
|
+
}
|
|
180
|
+
function seedSeatedRooms(key, rooms, dir = defaultKojeeDir()) {
|
|
181
|
+
writeSeatedRooms(key, [...new Set(rooms)], dir);
|
|
182
|
+
}
|
|
183
|
+
function writeSeatedRooms(key, rooms, dir) {
|
|
184
|
+
fs.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
185
|
+
secureDir(dir);
|
|
186
|
+
const filePath = seatedRoomsPath(key, dir);
|
|
187
|
+
const body = { schema: 1, rooms, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
188
|
+
fs.writeFileSync(filePath, JSON.stringify(body, null, 2), { mode: 384 });
|
|
189
|
+
secureFile(filePath);
|
|
190
|
+
}
|
|
191
|
+
function addSeatedRoom(key, tandemId, dir = defaultKojeeDir()) {
|
|
192
|
+
const rooms = readSeatedRooms(key, dir);
|
|
193
|
+
if (rooms.includes(tandemId)) return;
|
|
194
|
+
writeSeatedRooms(key, [...rooms, tandemId], dir);
|
|
195
|
+
}
|
|
196
|
+
function removeSeatedRoom(key, tandemId, dir = defaultKojeeDir()) {
|
|
197
|
+
const rooms = readSeatedRooms(key, dir);
|
|
198
|
+
if (!rooms.includes(tandemId)) return;
|
|
199
|
+
writeSeatedRooms(key, rooms.filter((r) => r !== tandemId), dir);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// src/index.ts
|
|
203
|
+
var DEFAULT_KEYSTORE_PATH = path2.join(os2.homedir(), ".kojee", "keypair.json");
|
|
204
|
+
function isDPoPEnrollmentError(err) {
|
|
205
|
+
const msg = String(err?.message ?? err ?? "").toLowerCase();
|
|
206
|
+
if (msg.includes("invalid or expired") && msg.includes("token")) return false;
|
|
207
|
+
if (msg.includes("generate a new")) return false;
|
|
208
|
+
return msg.includes("401") || msg.includes("authentication failed") || msg.includes("dpop") || msg.includes("key enrollment") || msg.includes("kid");
|
|
209
|
+
}
|
|
210
|
+
async function selectAdapter() {
|
|
211
|
+
const runtime = await detectRuntime();
|
|
212
|
+
if (runtime === "claude-code") return claudeCodeAdapter;
|
|
213
|
+
if (runtime === "codex") return codexAdapter;
|
|
214
|
+
return unknownAdapter;
|
|
215
|
+
}
|
|
216
|
+
async function listTandemIds(gateway) {
|
|
217
|
+
const result = await gateway.sendRpc("tools/call", { name: "tandem_list", arguments: {} });
|
|
218
|
+
const maybeErr = result;
|
|
219
|
+
if (maybeErr.isError) return null;
|
|
220
|
+
const text = maybeErr.content?.[0]?.text;
|
|
221
|
+
try {
|
|
222
|
+
const parsed = text ? JSON.parse(text) : {};
|
|
223
|
+
const list = Array.isArray(parsed.tandems) ? parsed.tandems : Array.isArray(parsed) ? parsed : null;
|
|
224
|
+
if (!Array.isArray(list)) return [];
|
|
225
|
+
return list.map((t) => {
|
|
226
|
+
if (typeof t === "string") return t;
|
|
227
|
+
const obj = t;
|
|
228
|
+
if (obj?.my_membership?.is_member !== true) return void 0;
|
|
229
|
+
return obj?.tandem_id ?? obj?.id;
|
|
230
|
+
}).filter((id) => typeof id === "string" && id.length > 0);
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
async function startProxy(config) {
|
|
236
|
+
const keystorePath = config.keystorePath || DEFAULT_KEYSTORE_PATH;
|
|
237
|
+
const adapter = await selectAdapter();
|
|
238
|
+
console.error(`[kojee-mcp] Starting proxy for ${config.url} (runtime=${adapter.runtime})`);
|
|
239
|
+
const ccPid = await findClaudeAncestorPid();
|
|
240
|
+
const { instanceKey } = await applyStableSessionId(config.token, { ccPid });
|
|
241
|
+
const { registry, gateway } = await enrollAndDiscover(config, keystorePath);
|
|
242
|
+
console.error(
|
|
243
|
+
`[kojee-mcp] Ready \u2014 ${registry.toolCount} tools available from ${config.url}`
|
|
244
|
+
);
|
|
245
|
+
const roomMemory = {
|
|
246
|
+
hasMemory: () => hasSeatedRoomsFile(instanceKey),
|
|
247
|
+
read: () => readSeatedRooms(instanceKey),
|
|
248
|
+
seed: (rooms) => seedSeatedRooms(instanceKey, rooms)
|
|
249
|
+
};
|
|
250
|
+
const recordRooms = parseTandemsConfig(process.env["KOJEE_TANDEMS"]).mode === "auto-local";
|
|
251
|
+
if (instanceKey.startsWith("wd-")) {
|
|
252
|
+
console.error(
|
|
253
|
+
"[kojee-mcp] degraded per-window fidelity: no per-window session id \u2014 two concurrent windows in the same project dir share ONE seat" + (recordRooms ? " and one room-memory file" : "") + ". Set KOJEE_INSTANCE=<unique-per-window> to keep them distinct."
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
let activeStreamHandle = null;
|
|
257
|
+
const { createJoinReconnectScheduler } = await import("./reconnect-scheduler-ARV6JIWK.js");
|
|
258
|
+
const joinReconnect = createJoinReconnectScheduler({
|
|
259
|
+
// BOOT-RACE (Bug B): report whether the stream handle was actually ready.
|
|
260
|
+
// `false` ⇒ activeStreamHandle is still null (tandem_join fired before the
|
|
261
|
+
// stream was set up) → the scheduler queues the reconnect and flushes it on
|
|
262
|
+
// notifyReady() once the handle is assigned (see below), instead of silently
|
|
263
|
+
// dropping it as the old `activeStreamHandle?.reconnect()` no-op did.
|
|
264
|
+
reconnect: () => {
|
|
265
|
+
if (!activeStreamHandle) return false;
|
|
266
|
+
activeStreamHandle.reconnect();
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
const onTandemJoin = (tandemId) => {
|
|
271
|
+
joinReconnect.requestReconnect();
|
|
272
|
+
if (recordRooms && tandemId) addSeatedRoom(instanceKey, tandemId);
|
|
273
|
+
};
|
|
274
|
+
const onTandemLeave = (tandemId) => {
|
|
275
|
+
if (recordRooms && tandemId) removeSeatedRoom(instanceKey, tandemId);
|
|
276
|
+
};
|
|
277
|
+
const teardownSteps = [];
|
|
278
|
+
let shuttingDown = false;
|
|
279
|
+
function shutdown(reason) {
|
|
280
|
+
if (shuttingDown) return;
|
|
281
|
+
shuttingDown = true;
|
|
282
|
+
activeStreamHandle?.();
|
|
283
|
+
for (const step of teardownSteps) {
|
|
284
|
+
try {
|
|
285
|
+
const maybe = step();
|
|
286
|
+
if (maybe && typeof maybe.catch === "function") {
|
|
287
|
+
maybe.catch((err) => {
|
|
288
|
+
console.error("[kojee-mcp] async shutdown step failed:", err?.message ?? err);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
} catch (err) {
|
|
292
|
+
console.error("[kojee-mcp] shutdown step failed:", err?.message ?? err);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
console.error(`[kojee-mcp] shutting down (${reason}), exiting`);
|
|
296
|
+
process.exit(0);
|
|
297
|
+
}
|
|
298
|
+
const { ensureJoinTandems } = await import("./ensure-join-5Y5IJ7HN.js");
|
|
299
|
+
await ensureJoinTandems({
|
|
300
|
+
gateway,
|
|
301
|
+
env: process.env["KOJEE_TANDEMS"],
|
|
302
|
+
listTandems: () => listTandemIds(gateway),
|
|
303
|
+
roomMemory,
|
|
304
|
+
onJoined: () => joinReconnect.requestReconnect()
|
|
305
|
+
});
|
|
306
|
+
let tandemMembershipCount = -1;
|
|
307
|
+
try {
|
|
308
|
+
const bootIds = await listTandemIds(gateway);
|
|
309
|
+
tandemMembershipCount = bootIds === null ? -1 : bootIds.length;
|
|
310
|
+
} catch (err) {
|
|
311
|
+
console.error("[kojee-mcp] tandem_list probe failed:", err.message);
|
|
312
|
+
}
|
|
313
|
+
console.error(`[kojee-mcp] Tandem memberships: ${tandemMembershipCount === -1 ? "unknown" : tandemMembershipCount}`);
|
|
314
|
+
let server;
|
|
315
|
+
const { selectDelivery } = await import("./registry-TGALQP6M.js");
|
|
316
|
+
const delivery = selectDelivery(adapter.runtime, {
|
|
317
|
+
supportsChannels: adapter.supportsChannels
|
|
318
|
+
});
|
|
319
|
+
if (delivery) {
|
|
320
|
+
const started = await delivery.start({
|
|
321
|
+
instanceKey,
|
|
322
|
+
runtime: adapter.runtime,
|
|
323
|
+
adapter,
|
|
324
|
+
config,
|
|
325
|
+
registry,
|
|
326
|
+
gateway,
|
|
327
|
+
ccPid,
|
|
328
|
+
tandemMembershipCount,
|
|
329
|
+
listTandemIds: () => listTandemIds(gateway),
|
|
330
|
+
toolCallHooks: { onTandemJoin, onTandemLeave },
|
|
331
|
+
onStreamReady: (handle) => {
|
|
332
|
+
activeStreamHandle = handle;
|
|
333
|
+
joinReconnect.notifyReady();
|
|
334
|
+
},
|
|
335
|
+
log: (line) => console.error(line)
|
|
336
|
+
});
|
|
337
|
+
server = started.server;
|
|
338
|
+
if (started.exitCleanup) {
|
|
339
|
+
const exitCleanup = started.exitCleanup;
|
|
340
|
+
process.on("exit", () => exitCleanup());
|
|
341
|
+
}
|
|
342
|
+
for (const step of started.teardown) teardownSteps.push(step);
|
|
343
|
+
} else {
|
|
344
|
+
server = createMcpServer(registry, adapter, tandemMembershipCount);
|
|
345
|
+
}
|
|
346
|
+
process.stdin.on("end", () => shutdown("stdin end"));
|
|
347
|
+
process.stdin.on("close", () => shutdown("stdin close"));
|
|
348
|
+
process.on("SIGHUP", () => shutdown("SIGHUP"));
|
|
349
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
350
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
351
|
+
if (ccPid !== null) {
|
|
352
|
+
const { createParentWatchdog } = await import("./parent-watchdog-TLU355FB.js");
|
|
353
|
+
const watchdog = createParentWatchdog({
|
|
354
|
+
ccPid,
|
|
355
|
+
onParentGone: () => shutdown("parent (Claude Code) gone")
|
|
356
|
+
});
|
|
357
|
+
watchdog.start();
|
|
358
|
+
teardownSteps.push(() => watchdog.stop());
|
|
359
|
+
} else {
|
|
360
|
+
console.error(
|
|
361
|
+
"[kojee-mcp] no Claude Code ancestor found \u2014 parent-liveness watchdog NOT armed (stdin/signal handlers still cover clean exits)"
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
await startMcpServer(server);
|
|
365
|
+
}
|
|
366
|
+
async function enrollAndDiscover(config, keystorePath, isRetry = false) {
|
|
367
|
+
const auth = new AuthModule(config.token, config.url, keystorePath);
|
|
368
|
+
const keyPair = await auth.ensureEnrolled();
|
|
369
|
+
const sessionId = GatewayClient.deriveSessionId(config.token);
|
|
370
|
+
console.error(`[kojee-mcp] Session: ${sessionId}`);
|
|
371
|
+
const gateway = new GatewayClient(
|
|
372
|
+
config.url,
|
|
373
|
+
config.token,
|
|
374
|
+
keyPair.privateKey,
|
|
375
|
+
keyPair.kid,
|
|
376
|
+
sessionId
|
|
377
|
+
);
|
|
378
|
+
const registry = new ToolRegistry(gateway);
|
|
379
|
+
try {
|
|
380
|
+
await registry.discoverTools();
|
|
381
|
+
return { registry, gateway };
|
|
382
|
+
} catch (err) {
|
|
383
|
+
if (isRetry || !isDPoPEnrollmentError(err)) {
|
|
384
|
+
throw err;
|
|
385
|
+
}
|
|
386
|
+
console.error(
|
|
387
|
+
"[kojee-mcp] Auth failed, attempting recovery with fresh enrollment..."
|
|
388
|
+
);
|
|
389
|
+
try {
|
|
390
|
+
if (fs2.existsSync(keystorePath)) fs2.unlinkSync(keystorePath);
|
|
391
|
+
} catch (unlinkErr) {
|
|
392
|
+
console.error("[kojee-mcp] Could not remove stale keystore:", unlinkErr);
|
|
393
|
+
}
|
|
394
|
+
return enrollAndDiscover(config, keystorePath, true);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export {
|
|
399
|
+
listTandemIds,
|
|
400
|
+
startProxy
|
|
401
|
+
};
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
} from "./chunk-
|
|
2
|
+
createDPoPProof,
|
|
3
|
+
getSessionId
|
|
4
|
+
} from "./chunk-NR4Y54OL.js";
|
|
5
5
|
|
|
6
6
|
// src/tandem/event-stream.ts
|
|
7
7
|
var STALE_FLOOR_MS = 9e4;
|
|
8
8
|
var STALE_INTERVAL_MULTIPLIER = 3;
|
|
9
9
|
var STALE_CHECK_INTERVAL_MS = 5e3;
|
|
10
10
|
var UNARMED_FALLBACK_MS = 12 * 6e4;
|
|
11
|
+
var UNDICI_DEFAULT_BODY_TIMEOUT_MS = 3e5;
|
|
11
12
|
function createAdaptiveWatchdog(options = {}) {
|
|
12
13
|
const floorMs = options.floorMs ?? STALE_FLOOR_MS;
|
|
13
14
|
const multiplier = options.multiplier ?? STALE_INTERVAL_MULTIPLIER;
|
|
@@ -179,7 +180,7 @@ async function openStream(opts, controller, sinceValue) {
|
|
|
179
180
|
headers: {
|
|
180
181
|
Authorization: `DPoP ${opts.token}`,
|
|
181
182
|
DPoP: proof,
|
|
182
|
-
"Mcp-Session-Id":
|
|
183
|
+
"Mcp-Session-Id": getSessionId(),
|
|
183
184
|
Accept: "text/event-stream"
|
|
184
185
|
},
|
|
185
186
|
signal: controller.signal
|
|
@@ -232,6 +233,14 @@ async function consumeSse(body, opts, controller, state, onCursor) {
|
|
|
232
233
|
const parsed = normalizeBackendEvent(raw, evt.event);
|
|
233
234
|
onCursor(parsed.tandem_id, parsed.cursor);
|
|
234
235
|
if (parsed.wake === false) continue;
|
|
236
|
+
if (opts.delivery) {
|
|
237
|
+
try {
|
|
238
|
+
await opts.delivery.deliver(parsed);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
console.error("[event-stream] delivery.deliver failed:", err);
|
|
241
|
+
}
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
235
244
|
opts.queue?.push(parsed);
|
|
236
245
|
if (opts.eventLog) {
|
|
237
246
|
try {
|
|
@@ -398,8 +407,11 @@ function normalizeBackendEvent(raw, sseEventType) {
|
|
|
398
407
|
}
|
|
399
408
|
|
|
400
409
|
export {
|
|
410
|
+
UNDICI_DEFAULT_BODY_TIMEOUT_MS,
|
|
401
411
|
createAdaptiveWatchdog,
|
|
412
|
+
createBackoffController,
|
|
402
413
|
startEventStream,
|
|
414
|
+
serializeCursorMap,
|
|
403
415
|
sanitizeDisplayname,
|
|
404
416
|
normalizeBackendEvent
|
|
405
417
|
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildCatchUpNote,
|
|
3
|
+
buildMonitorSpawn,
|
|
4
|
+
buildReplyRecipe
|
|
5
|
+
} from "./chunk-X672ZN7V.js";
|
|
6
|
+
import {
|
|
7
|
+
translateToolCallResult
|
|
8
|
+
} from "./chunk-2OLXXOT3.js";
|
|
9
|
+
|
|
10
|
+
// src/server.ts
|
|
11
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
12
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
|
+
import {
|
|
14
|
+
ListToolsRequestSchema,
|
|
15
|
+
CallToolRequestSchema
|
|
16
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
17
|
+
|
|
18
|
+
// src/version.ts
|
|
19
|
+
import fs from "fs";
|
|
20
|
+
import path from "path";
|
|
21
|
+
import { fileURLToPath } from "url";
|
|
22
|
+
var FALLBACK_VERSION = "0.0.0-unknown";
|
|
23
|
+
function resolveVersion() {
|
|
24
|
+
try {
|
|
25
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const parsed = JSON.parse(
|
|
27
|
+
fs.readFileSync(path.join(here, "..", "package.json"), "utf8")
|
|
28
|
+
);
|
|
29
|
+
return typeof parsed?.version === "string" && parsed.version ? parsed.version : FALLBACK_VERSION;
|
|
30
|
+
} catch (err) {
|
|
31
|
+
process.stderr.write(
|
|
32
|
+
`kojee-mcp: could not resolve version from package.json, falling back to ${FALLBACK_VERSION}: ${String(err)}
|
|
33
|
+
`
|
|
34
|
+
);
|
|
35
|
+
return FALLBACK_VERSION;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
var VERSION = resolveVersion();
|
|
39
|
+
|
|
40
|
+
// src/server.ts
|
|
41
|
+
function buildChannelInstructions(_tandemMembershipCount, eventLogPath) {
|
|
42
|
+
const intro = `Tandem events are delivered to you in three ways depending on which is active in your Claude Code session:
|
|
43
|
+
|
|
44
|
+
(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()}.
|
|
45
|
+
|
|
46
|
+
`;
|
|
47
|
+
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.)
|
|
48
|
+
|
|
49
|
+
`;
|
|
50
|
+
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.";
|
|
51
|
+
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.";
|
|
52
|
+
return intro + monitorSection + listenSection + advice;
|
|
53
|
+
}
|
|
54
|
+
function tandemIdArg(args) {
|
|
55
|
+
return typeof args["tandem_id"] === "string" ? args["tandem_id"] : null;
|
|
56
|
+
}
|
|
57
|
+
async function executeToolCall(registry, name, args, hooks) {
|
|
58
|
+
const rawResult = await registry.callTool(name, args);
|
|
59
|
+
const result = translateToolCallResult(rawResult);
|
|
60
|
+
if (!result.isError) {
|
|
61
|
+
if (name === "tandem_join") {
|
|
62
|
+
try {
|
|
63
|
+
hooks?.onTandemJoin?.(tandemIdArg(args));
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.error("[mcp] onTandemJoin hook failed:", err?.message ?? String(err));
|
|
66
|
+
}
|
|
67
|
+
} else if (name === "tandem_leave") {
|
|
68
|
+
try {
|
|
69
|
+
hooks?.onTandemLeave?.(tandemIdArg(args));
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.error("[mcp] onTandemLeave hook failed:", err?.message ?? String(err));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
function createMcpServer(registry, adapter, tandemMembershipCount = -1, eventLogPath, hooks) {
|
|
78
|
+
const capabilities = { tools: {} };
|
|
79
|
+
if (adapter.supportsChannels) {
|
|
80
|
+
capabilities.experimental = { "claude/channel": {} };
|
|
81
|
+
}
|
|
82
|
+
const server = new Server(
|
|
83
|
+
{ name: "kojee-mcp", version: VERSION },
|
|
84
|
+
{
|
|
85
|
+
capabilities,
|
|
86
|
+
...adapter.supportsChannels ? { instructions: buildChannelInstructions(tandemMembershipCount, eventLogPath ?? "") } : {}
|
|
87
|
+
}
|
|
88
|
+
);
|
|
89
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
90
|
+
const tools = registry.getAllTools().map((t) => ({
|
|
91
|
+
name: t.name,
|
|
92
|
+
description: t.description,
|
|
93
|
+
inputSchema: t.inputSchema
|
|
94
|
+
}));
|
|
95
|
+
return { tools };
|
|
96
|
+
});
|
|
97
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
98
|
+
const { name, arguments: args } = request.params;
|
|
99
|
+
const result = await executeToolCall(registry, name, args ?? {}, hooks);
|
|
100
|
+
return { content: result.content, isError: result.isError };
|
|
101
|
+
});
|
|
102
|
+
return server;
|
|
103
|
+
}
|
|
104
|
+
async function startMcpServer(server) {
|
|
105
|
+
const transport = new StdioServerTransport();
|
|
106
|
+
await server.connect(transport);
|
|
107
|
+
console.error("[mcp] Server started on stdio transport");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export {
|
|
111
|
+
VERSION,
|
|
112
|
+
buildChannelInstructions,
|
|
113
|
+
executeToolCall,
|
|
114
|
+
createMcpServer,
|
|
115
|
+
startMcpServer
|
|
116
|
+
};
|