kojee-mcp 0.5.10 → 0.5.12
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/{codex-stop-hook-SWA53ECG.js → chunk-35XBRG3V.js} +4 -3
- package/dist/{chunk-MKDMAAMN.js → chunk-5XP2UOFK.js} +12 -0
- package/dist/{chunk-DS26OORG.js → chunk-CO73VGWM.js} +41 -23
- package/dist/chunk-FQZCENSG.js +459 -0
- package/dist/{chunk-YKS6YZKM.js → chunk-PHXO5P25.js} +1 -4
- package/dist/chunk-WLMPCX7T.js +116 -0
- package/dist/chunk-XLKGPGZT.js +0 -0
- package/dist/chunk-XXFVWP6H.js +44 -0
- package/dist/{ensure-join-7AEDJMPE.js → chunk-YKW54DKF.js} +45 -15
- package/dist/cli.js +16 -13
- package/dist/codex-stop-hook-VY7DOMAG.js +16 -0
- package/dist/{doctor-XK335W7B.js → doctor-FVTALRQD.js} +110 -15
- package/dist/ensure-join-5Y5IJ7HN.js +8 -0
- package/dist/{event-log-B27VVEMK.js → event-log-VZD7NKYX.js} +1 -1
- package/dist/event-stream-FOT7MJZH.js +19 -0
- package/dist/{gateway-client-93P1E0CZ.d.ts → gateway-client-C6yx1mfM.d.ts} +6 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +7 -5
- package/dist/lib.d.ts +181 -3
- package/dist/lib.js +7 -7
- package/dist/{parent-watchdog-RZLHYP7T.js → parent-watchdog-TLU355FB.js} +1 -1
- package/dist/registry-A3VT6VJD.js +348 -0
- package/dist/server-77QRWKJM.js +14 -0
- package/dist/{stop-hook-OTCJGL6V.js → stop-hook-CUVDKXP7.js} +8 -7
- package/dist/{tail-stream-JNR4WFW3.js → tail-stream-VZ462ZON.js} +3 -2
- package/dist/{user-prompt-submit-hook-QXMC7EZU.js → user-prompt-submit-hook-PMBUPKUV.js} +6 -6
- package/dist/{webhook-sink-NWGCUDGY.js → webhook-sink-N6AUTFL3.js} +1 -1
- package/package.json +1 -1
- package/dist/chunk-SCDWPGH3.js +0 -637
- package/dist/{chunk-BJMASMKX.js → chunk-VHKPWUX7.js} +0 -0
- package/dist/{doctor-codex-SMROUYGV.js → doctor-codex-PA3WO6LR.js} +1 -1
- package/dist/{send-cli-CN5EX7PO.js → send-cli-NZP5XE7T.js} +5 -5
- 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
|
|
@@ -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
|
};
|
|
@@ -8,6 +8,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;
|
|
@@ -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
|
};
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
secureFile
|
|
6
6
|
} from "./chunk-BLEGIR35.js";
|
|
7
7
|
|
|
8
|
-
// src/
|
|
8
|
+
// src/delivery/lib/event-log.ts
|
|
9
9
|
import fs from "fs";
|
|
10
10
|
import os from "os";
|
|
11
11
|
import path from "path";
|
|
@@ -21,13 +21,24 @@ function statusLogPath(eventLogPath) {
|
|
|
21
21
|
const m = base.match(/^kojee-events-(.+)\.log$/);
|
|
22
22
|
return path.join(dir, m ? `kojee-status-${m[1]}.log` : `${base}.status`);
|
|
23
23
|
}
|
|
24
|
+
function livePathOwnedBySibling(candidatePath) {
|
|
25
|
+
try {
|
|
26
|
+
const { livePaths } = listActiveSessionIds();
|
|
27
|
+
return livePaths.has(path.resolve(candidatePath));
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
24
32
|
function startEventLog(opts) {
|
|
25
33
|
const dir = opts.dir ?? DEFAULT_DIR;
|
|
26
34
|
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
27
35
|
const statusMaxBytes = opts.statusMaxBytes ?? DEFAULT_STATUS_MAX_BYTES;
|
|
28
|
-
const filePath = path.join(dir, `kojee-events-${opts.key}.log`);
|
|
29
|
-
const statusPath = statusLogPath(filePath);
|
|
30
36
|
fs.mkdirSync(dir, { recursive: true });
|
|
37
|
+
let filePath = path.join(dir, `kojee-events-${opts.key}.log`);
|
|
38
|
+
if (opts.key.startsWith("wd-") && livePathOwnedBySibling(filePath)) {
|
|
39
|
+
filePath = path.join(dir, `kojee-events-${opts.key}-${process.pid}.log`);
|
|
40
|
+
}
|
|
41
|
+
const statusPath = statusLogPath(filePath);
|
|
31
42
|
fs.writeFileSync(filePath, "", { mode: 384 });
|
|
32
43
|
secureFile(filePath);
|
|
33
44
|
fs.writeFileSync(statusPath, "", { mode: 384 });
|
|
@@ -146,6 +157,19 @@ function nudgeSentinelPath(eventLogPath) {
|
|
|
146
157
|
const m = base.match(/^kojee-events-(.+)\.log$/);
|
|
147
158
|
return path.join(dir, m ? `kojee-nudge-${m[1]}.touch` : `${base}.nudge`);
|
|
148
159
|
}
|
|
160
|
+
function reapEventLogSiblings(messagesLogPath) {
|
|
161
|
+
for (const p of [
|
|
162
|
+
messagesLogPath,
|
|
163
|
+
statusLogPath(messagesLogPath),
|
|
164
|
+
monitorHeartbeatPath(messagesLogPath),
|
|
165
|
+
nudgeSentinelPath(messagesLogPath)
|
|
166
|
+
]) {
|
|
167
|
+
try {
|
|
168
|
+
fs.unlinkSync(p);
|
|
169
|
+
} catch {
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
149
173
|
function isProcessAlive(pid) {
|
|
150
174
|
try {
|
|
151
175
|
process.kill(pid, 0);
|
|
@@ -165,6 +189,7 @@ function listActiveSessionIds() {
|
|
|
165
189
|
} catch {
|
|
166
190
|
return { active, livePaths };
|
|
167
191
|
}
|
|
192
|
+
const dead = [];
|
|
168
193
|
for (const name of entries) {
|
|
169
194
|
if (!name.endsWith(".json")) continue;
|
|
170
195
|
const rawId = name.slice(0, -".json".length);
|
|
@@ -172,28 +197,25 @@ function listActiveSessionIds() {
|
|
|
172
197
|
const filePath = path.join(dir, name);
|
|
173
198
|
try {
|
|
174
199
|
const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
175
|
-
|
|
200
|
+
const livenessPid = data.proxyPid ?? data.pid;
|
|
201
|
+
if (typeof livenessPid === "number" && isProcessAlive(livenessPid)) {
|
|
176
202
|
active.add(sessionId);
|
|
177
203
|
if (data.eventLogPath) livePaths.add(path.resolve(data.eventLogPath));
|
|
178
204
|
} else {
|
|
179
|
-
|
|
180
|
-
fs.unlinkSync(filePath);
|
|
181
|
-
} catch {
|
|
182
|
-
}
|
|
183
|
-
if (data.eventLogPath) {
|
|
184
|
-
try {
|
|
185
|
-
fs.unlinkSync(data.eventLogPath);
|
|
186
|
-
} catch {
|
|
187
|
-
}
|
|
188
|
-
try {
|
|
189
|
-
fs.unlinkSync(statusLogPath(data.eventLogPath));
|
|
190
|
-
} catch {
|
|
191
|
-
}
|
|
192
|
-
}
|
|
205
|
+
dead.push({ filePath, eventLogPath: data.eventLogPath });
|
|
193
206
|
}
|
|
194
207
|
} catch {
|
|
195
208
|
}
|
|
196
209
|
}
|
|
210
|
+
for (const { filePath, eventLogPath } of dead) {
|
|
211
|
+
try {
|
|
212
|
+
fs.unlinkSync(filePath);
|
|
213
|
+
} catch {
|
|
214
|
+
}
|
|
215
|
+
if (eventLogPath && !livePaths.has(path.resolve(eventLogPath))) {
|
|
216
|
+
reapEventLogSiblings(eventLogPath);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
197
219
|
return { active, livePaths };
|
|
198
220
|
}
|
|
199
221
|
function sweepStaleEventLogs(dir = DEFAULT_DIR, minAgeMs = DEFAULT_MIN_AGE_MS) {
|
|
@@ -219,11 +241,7 @@ function sweepStaleEventLogs(dir = DEFAULT_DIR, minAgeMs = DEFAULT_MIN_AGE_MS) {
|
|
|
219
241
|
const stat = fs.statSync(filePath);
|
|
220
242
|
const ageMs = Math.max(0, now - stat.mtimeMs);
|
|
221
243
|
if (ageMs < minAgeMs) continue;
|
|
222
|
-
|
|
223
|
-
try {
|
|
224
|
-
fs.unlinkSync(statusLogPath(filePath));
|
|
225
|
-
} catch {
|
|
226
|
-
}
|
|
244
|
+
reapEventLogSiblings(filePath);
|
|
227
245
|
} catch {
|
|
228
246
|
}
|
|
229
247
|
}
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import {
|
|
2
|
+
claudeCodeAdapter
|
|
3
|
+
} from "./chunk-XXFVWP6H.js";
|
|
4
|
+
import {
|
|
5
|
+
GatewayClient
|
|
6
|
+
} from "./chunk-HSR3GXCL.js";
|
|
7
|
+
import {
|
|
8
|
+
AuthModule
|
|
9
|
+
} from "./chunk-JXMVZEQ7.js";
|
|
10
|
+
import {
|
|
11
|
+
secureDir,
|
|
12
|
+
secureFile
|
|
13
|
+
} from "./chunk-BLEGIR35.js";
|
|
14
|
+
import {
|
|
15
|
+
createMcpServer,
|
|
16
|
+
startMcpServer
|
|
17
|
+
} from "./chunk-WLMPCX7T.js";
|
|
18
|
+
import {
|
|
19
|
+
findClaudeAncestorPid
|
|
20
|
+
} from "./chunk-VHKPWUX7.js";
|
|
21
|
+
import {
|
|
22
|
+
parseTandemsConfig
|
|
23
|
+
} from "./chunk-YKW54DKF.js";
|
|
24
|
+
|
|
25
|
+
// src/index.ts
|
|
26
|
+
import fs3 from "fs";
|
|
27
|
+
import os2 from "os";
|
|
28
|
+
import path2 from "path";
|
|
29
|
+
|
|
30
|
+
// src/tool-registry.ts
|
|
31
|
+
var ToolRegistry = class {
|
|
32
|
+
constructor(gateway) {
|
|
33
|
+
this.gateway = gateway;
|
|
34
|
+
}
|
|
35
|
+
gateway;
|
|
36
|
+
/** Flat map: tool name → full tool definition */
|
|
37
|
+
tools = /* @__PURE__ */ new Map();
|
|
38
|
+
/**
|
|
39
|
+
* Fetch all tools with full schemas from the gateway in a single RPC call.
|
|
40
|
+
*/
|
|
41
|
+
async discoverTools() {
|
|
42
|
+
console.error("[tools] Fetching tools with full schemas from gateway...");
|
|
43
|
+
const result = await this.gateway.sendRpc("tools/list", {
|
|
44
|
+
include_schema: true
|
|
45
|
+
});
|
|
46
|
+
const maybeError = result;
|
|
47
|
+
if (maybeError.isError) {
|
|
48
|
+
const msg = maybeError.content?.[0]?.text ?? "unknown error";
|
|
49
|
+
throw new Error(`Gateway rejected tools/list: ${msg}`);
|
|
50
|
+
}
|
|
51
|
+
const toolList = result?.tools;
|
|
52
|
+
if (!toolList || !Array.isArray(toolList)) {
|
|
53
|
+
console.error("[tools] No tools returned from gateway");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
for (const tool of toolList) {
|
|
57
|
+
if (this.tools.has(tool.name)) {
|
|
58
|
+
console.error(`[tools] Warning: duplicate tool name "${tool.name}" \u2014 overwriting`);
|
|
59
|
+
}
|
|
60
|
+
this.tools.set(tool.name, tool);
|
|
61
|
+
}
|
|
62
|
+
console.error(`[tools] Registered ${this.tools.size} tools from gateway`);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Return all registered tools for the MCP ListTools response.
|
|
66
|
+
*/
|
|
67
|
+
getAllTools() {
|
|
68
|
+
return Array.from(this.tools.values());
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Call a tool through the gateway.
|
|
72
|
+
*/
|
|
73
|
+
async callTool(name, args) {
|
|
74
|
+
if (!this.tools.has(name)) {
|
|
75
|
+
const available = Array.from(this.tools.keys()).slice(0, 10).join(", ");
|
|
76
|
+
return {
|
|
77
|
+
content: [
|
|
78
|
+
{
|
|
79
|
+
type: "text",
|
|
80
|
+
text: `Tool '${name}' not found. Some available tools: ${available}${this.tools.size > 10 ? ", ..." : ""}`
|
|
81
|
+
}
|
|
82
|
+
],
|
|
83
|
+
isError: true
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return this.gateway.sendRpc("tools/call", {
|
|
87
|
+
name,
|
|
88
|
+
arguments: args
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/** Total number of registered tools. */
|
|
92
|
+
get toolCount() {
|
|
93
|
+
return this.tools.size;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// src/runtime/detect.ts
|
|
98
|
+
import psList from "ps-list";
|
|
99
|
+
async function detectRuntime(env = process.env) {
|
|
100
|
+
if (env["KOJEE_RUNTIME"] === "claude-code") return "claude-code";
|
|
101
|
+
if (env["KOJEE_RUNTIME"] === "codex") return "codex";
|
|
102
|
+
if (env["CLAUDE_CODE_SESSION_ID"]) return "claude-code";
|
|
103
|
+
const ancestor = await detectRuntimeFromAncestry();
|
|
104
|
+
if (ancestor) return ancestor;
|
|
105
|
+
return "unknown";
|
|
106
|
+
}
|
|
107
|
+
async function detectRuntimeFromAncestry() {
|
|
108
|
+
try {
|
|
109
|
+
const processes = await psList();
|
|
110
|
+
const byPid = new Map(processes.map((p) => [p.pid, p]));
|
|
111
|
+
let pid = process.ppid;
|
|
112
|
+
for (let depth = 0; depth < 5 && pid && pid > 1; depth++) {
|
|
113
|
+
const row = byPid.get(pid);
|
|
114
|
+
if (!row) return null;
|
|
115
|
+
const haystack = `${row.name} ${row.cmd ?? ""}`;
|
|
116
|
+
if (/(^|\/|\\)claude(\.app|\.exe)?(\/|$|\s|\\)/i.test(haystack) || /Claude Helper/i.test(haystack)) {
|
|
117
|
+
return "claude-code";
|
|
118
|
+
}
|
|
119
|
+
if (/(^|\/|\\)codex(\.exe)?(\/|$|\s|\\)/i.test(haystack)) {
|
|
120
|
+
return "codex";
|
|
121
|
+
}
|
|
122
|
+
pid = row.ppid;
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// src/adapters/codex.ts
|
|
130
|
+
var codexAdapter = {
|
|
131
|
+
runtime: "codex",
|
|
132
|
+
supportsChannels: false,
|
|
133
|
+
// Codex has NO Claude-style channel injection
|
|
134
|
+
formatTandemEvent() {
|
|
135
|
+
throw new Error(
|
|
136
|
+
"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."
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// src/adapters/unknown.ts
|
|
142
|
+
var unknownAdapter = {
|
|
143
|
+
runtime: "unknown",
|
|
144
|
+
supportsChannels: false,
|
|
145
|
+
formatTandemEvent() {
|
|
146
|
+
throw new Error(
|
|
147
|
+
"unknownAdapter.formatTandemEvent() is unreachable \u2014 caller should gate on supportsChannels"
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// src/runtime/cc-session-id.ts
|
|
153
|
+
import fs from "fs";
|
|
154
|
+
import { execFileSync } from "child_process";
|
|
155
|
+
import { createHash } from "crypto";
|
|
156
|
+
var CC_SESSION_ENV = "CLAUDE_CODE_SESSION_ID";
|
|
157
|
+
function extractCcSessionId(raw) {
|
|
158
|
+
if (raw.includes("\0")) {
|
|
159
|
+
for (const entry of raw.split("\0")) {
|
|
160
|
+
if (entry.startsWith(`${CC_SESSION_ENV}=`)) {
|
|
161
|
+
const v = entry.slice(CC_SESSION_ENV.length + 1);
|
|
162
|
+
return v.length > 0 ? v : null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
const re = new RegExp(`(?:^|\\s)${CC_SESSION_ENV}=([^\\s]+)`, "g");
|
|
168
|
+
let last = null;
|
|
169
|
+
let m;
|
|
170
|
+
while ((m = re.exec(raw)) !== null) last = m[1];
|
|
171
|
+
return last;
|
|
172
|
+
}
|
|
173
|
+
function defaultReadProcessEnvRaw(pid, platform) {
|
|
174
|
+
try {
|
|
175
|
+
if (platform === "linux") {
|
|
176
|
+
return fs.readFileSync(`/proc/${pid}/environ`, "utf8");
|
|
177
|
+
}
|
|
178
|
+
if (platform === "darwin") {
|
|
179
|
+
return execFileSync("ps", ["eww", "-p", String(pid), "-o", "command="], {
|
|
180
|
+
encoding: "utf8",
|
|
181
|
+
timeout: 2e3
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
} catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
function sanitizeKey(value) {
|
|
190
|
+
return value.replace(/[^A-Za-z0-9_-]/g, "");
|
|
191
|
+
}
|
|
192
|
+
function resolveInstanceKey(deps = {}) {
|
|
193
|
+
const env = deps.env ?? process.env;
|
|
194
|
+
const platform = deps.platform ?? process.platform;
|
|
195
|
+
const ccPid = deps.ccPid ?? null;
|
|
196
|
+
if (ccPid !== null) {
|
|
197
|
+
const reader = deps.readProcessEnvRaw ?? defaultReadProcessEnvRaw;
|
|
198
|
+
const raw = reader(ccPid, platform);
|
|
199
|
+
if (raw) {
|
|
200
|
+
const sid = sanitizeKey(extractCcSessionId(raw) ?? "");
|
|
201
|
+
if (sid) return sid;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const explicit = sanitizeKey((env.KOJEE_INSTANCE ?? "").trim());
|
|
205
|
+
if (explicit) return `inst-${explicit}`;
|
|
206
|
+
const base = deps.projectDir ?? env.CLAUDE_PROJECT_DIR ?? deps.cwd ?? process.cwd() ?? "";
|
|
207
|
+
const hash = createHash("sha256").update(base).digest("hex").slice(0, 12);
|
|
208
|
+
return `wd-${hash}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/tandem/room-memory.ts
|
|
212
|
+
import fs2 from "fs";
|
|
213
|
+
import os from "os";
|
|
214
|
+
import path from "path";
|
|
215
|
+
function defaultKojeeDir() {
|
|
216
|
+
return path.join(os.homedir(), ".kojee");
|
|
217
|
+
}
|
|
218
|
+
function seatedRoomsPath(key, dir = defaultKojeeDir()) {
|
|
219
|
+
return path.join(dir, `seated-rooms-cc-${key}.json`);
|
|
220
|
+
}
|
|
221
|
+
function readSeatedRooms(key, dir = defaultKojeeDir()) {
|
|
222
|
+
let raw;
|
|
223
|
+
try {
|
|
224
|
+
raw = fs2.readFileSync(seatedRoomsPath(key, dir), "utf8");
|
|
225
|
+
} catch {
|
|
226
|
+
return [];
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
const parsed = JSON.parse(raw);
|
|
230
|
+
return Array.isArray(parsed.rooms) ? parsed.rooms.filter((r) => typeof r === "string") : [];
|
|
231
|
+
} catch {
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function hasSeatedRoomsFile(key, dir = defaultKojeeDir()) {
|
|
236
|
+
return fs2.existsSync(seatedRoomsPath(key, dir));
|
|
237
|
+
}
|
|
238
|
+
function seedSeatedRooms(key, rooms, dir = defaultKojeeDir()) {
|
|
239
|
+
writeSeatedRooms(key, [...new Set(rooms)], dir);
|
|
240
|
+
}
|
|
241
|
+
function writeSeatedRooms(key, rooms, dir) {
|
|
242
|
+
fs2.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
243
|
+
secureDir(dir);
|
|
244
|
+
const filePath = seatedRoomsPath(key, dir);
|
|
245
|
+
const body = { schema: 1, rooms, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
246
|
+
fs2.writeFileSync(filePath, JSON.stringify(body, null, 2), { mode: 384 });
|
|
247
|
+
secureFile(filePath);
|
|
248
|
+
}
|
|
249
|
+
function addSeatedRoom(key, tandemId, dir = defaultKojeeDir()) {
|
|
250
|
+
const rooms = readSeatedRooms(key, dir);
|
|
251
|
+
if (rooms.includes(tandemId)) return;
|
|
252
|
+
writeSeatedRooms(key, [...rooms, tandemId], dir);
|
|
253
|
+
}
|
|
254
|
+
function removeSeatedRoom(key, tandemId, dir = defaultKojeeDir()) {
|
|
255
|
+
const rooms = readSeatedRooms(key, dir);
|
|
256
|
+
if (!rooms.includes(tandemId)) return;
|
|
257
|
+
writeSeatedRooms(key, rooms.filter((r) => r !== tandemId), dir);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/index.ts
|
|
261
|
+
var DEFAULT_KEYSTORE_PATH = path2.join(os2.homedir(), ".kojee", "keypair.json");
|
|
262
|
+
function isDPoPEnrollmentError(err) {
|
|
263
|
+
const msg = String(err?.message ?? err ?? "").toLowerCase();
|
|
264
|
+
if (msg.includes("invalid or expired") && msg.includes("token")) return false;
|
|
265
|
+
if (msg.includes("generate a new")) return false;
|
|
266
|
+
return msg.includes("401") || msg.includes("authentication failed") || msg.includes("dpop") || msg.includes("key enrollment") || msg.includes("kid");
|
|
267
|
+
}
|
|
268
|
+
async function selectAdapter() {
|
|
269
|
+
const runtime = await detectRuntime();
|
|
270
|
+
if (runtime === "claude-code") return claudeCodeAdapter;
|
|
271
|
+
if (runtime === "codex") return codexAdapter;
|
|
272
|
+
return unknownAdapter;
|
|
273
|
+
}
|
|
274
|
+
async function listTandemIds(gateway) {
|
|
275
|
+
const result = await gateway.sendRpc("tools/call", { name: "tandem_list", arguments: {} });
|
|
276
|
+
const maybeErr = result;
|
|
277
|
+
if (maybeErr.isError) return null;
|
|
278
|
+
const text = maybeErr.content?.[0]?.text;
|
|
279
|
+
try {
|
|
280
|
+
const parsed = text ? JSON.parse(text) : {};
|
|
281
|
+
const list = Array.isArray(parsed.tandems) ? parsed.tandems : Array.isArray(parsed) ? parsed : null;
|
|
282
|
+
if (!Array.isArray(list)) return [];
|
|
283
|
+
return list.map((t) => {
|
|
284
|
+
if (typeof t === "string") return t;
|
|
285
|
+
const obj = t;
|
|
286
|
+
if (obj?.my_membership?.is_member !== true) return void 0;
|
|
287
|
+
return obj?.tandem_id ?? obj?.id;
|
|
288
|
+
}).filter((id) => typeof id === "string" && id.length > 0);
|
|
289
|
+
} catch {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
async function startProxy(config) {
|
|
294
|
+
const keystorePath = config.keystorePath || DEFAULT_KEYSTORE_PATH;
|
|
295
|
+
const adapter = await selectAdapter();
|
|
296
|
+
console.error(`[kojee-mcp] Starting proxy for ${config.url} (runtime=${adapter.runtime})`);
|
|
297
|
+
const { registry, gateway } = await enrollAndDiscover(config, keystorePath);
|
|
298
|
+
console.error(
|
|
299
|
+
`[kojee-mcp] Ready \u2014 ${registry.toolCount} tools available from ${config.url}`
|
|
300
|
+
);
|
|
301
|
+
const ccPid = await findClaudeAncestorPid();
|
|
302
|
+
const instanceKey = resolveInstanceKey({ ccPid });
|
|
303
|
+
const roomMemory = {
|
|
304
|
+
hasMemory: () => hasSeatedRoomsFile(instanceKey),
|
|
305
|
+
read: () => readSeatedRooms(instanceKey),
|
|
306
|
+
seed: (rooms) => seedSeatedRooms(instanceKey, rooms)
|
|
307
|
+
};
|
|
308
|
+
const recordRooms = parseTandemsConfig(process.env["KOJEE_TANDEMS"]).mode === "auto-local";
|
|
309
|
+
if (recordRooms && instanceKey.startsWith("wd-")) {
|
|
310
|
+
console.error(
|
|
311
|
+
"[kojee-mcp] #28: no Claude Code session id available \u2014 room-memory keyed on project-dir hash; concurrent windows in the same dir will share it. Set KOJEE_INSTANCE=<unique-per-window> to keep them session-faithful."
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
let activeStreamHandle = null;
|
|
315
|
+
const { createJoinReconnectScheduler } = await import("./reconnect-scheduler-ARV6JIWK.js");
|
|
316
|
+
const joinReconnect = createJoinReconnectScheduler({
|
|
317
|
+
// BOOT-RACE (Bug B): report whether the stream handle was actually ready.
|
|
318
|
+
// `false` ⇒ activeStreamHandle is still null (tandem_join fired before the
|
|
319
|
+
// stream was set up) → the scheduler queues the reconnect and flushes it on
|
|
320
|
+
// notifyReady() once the handle is assigned (see below), instead of silently
|
|
321
|
+
// dropping it as the old `activeStreamHandle?.reconnect()` no-op did.
|
|
322
|
+
reconnect: () => {
|
|
323
|
+
if (!activeStreamHandle) return false;
|
|
324
|
+
activeStreamHandle.reconnect();
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
const onTandemJoin = (tandemId) => {
|
|
329
|
+
joinReconnect.requestReconnect();
|
|
330
|
+
if (recordRooms && tandemId) addSeatedRoom(instanceKey, tandemId);
|
|
331
|
+
};
|
|
332
|
+
const onTandemLeave = (tandemId) => {
|
|
333
|
+
if (recordRooms && tandemId) removeSeatedRoom(instanceKey, tandemId);
|
|
334
|
+
};
|
|
335
|
+
const teardownSteps = [];
|
|
336
|
+
let shuttingDown = false;
|
|
337
|
+
function shutdown(reason) {
|
|
338
|
+
if (shuttingDown) return;
|
|
339
|
+
shuttingDown = true;
|
|
340
|
+
activeStreamHandle?.();
|
|
341
|
+
for (const step of teardownSteps) {
|
|
342
|
+
try {
|
|
343
|
+
const maybe = step();
|
|
344
|
+
if (maybe && typeof maybe.catch === "function") {
|
|
345
|
+
maybe.catch((err) => {
|
|
346
|
+
console.error("[kojee-mcp] async shutdown step failed:", err?.message ?? err);
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
} catch (err) {
|
|
350
|
+
console.error("[kojee-mcp] shutdown step failed:", err?.message ?? err);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
console.error(`[kojee-mcp] shutting down (${reason}), exiting`);
|
|
354
|
+
process.exit(0);
|
|
355
|
+
}
|
|
356
|
+
const { ensureJoinTandems } = await import("./ensure-join-5Y5IJ7HN.js");
|
|
357
|
+
await ensureJoinTandems({
|
|
358
|
+
gateway,
|
|
359
|
+
env: process.env["KOJEE_TANDEMS"],
|
|
360
|
+
listTandems: () => listTandemIds(gateway),
|
|
361
|
+
roomMemory,
|
|
362
|
+
onJoined: () => joinReconnect.requestReconnect()
|
|
363
|
+
});
|
|
364
|
+
let tandemMembershipCount = -1;
|
|
365
|
+
try {
|
|
366
|
+
const bootIds = await listTandemIds(gateway);
|
|
367
|
+
tandemMembershipCount = bootIds === null ? -1 : bootIds.length;
|
|
368
|
+
} catch (err) {
|
|
369
|
+
console.error("[kojee-mcp] tandem_list probe failed:", err.message);
|
|
370
|
+
}
|
|
371
|
+
console.error(`[kojee-mcp] Tandem memberships: ${tandemMembershipCount === -1 ? "unknown" : tandemMembershipCount}`);
|
|
372
|
+
let server;
|
|
373
|
+
const { selectDelivery } = await import("./registry-A3VT6VJD.js");
|
|
374
|
+
const delivery = selectDelivery(adapter.runtime, {
|
|
375
|
+
supportsChannels: adapter.supportsChannels
|
|
376
|
+
});
|
|
377
|
+
if (delivery) {
|
|
378
|
+
const started = await delivery.start({
|
|
379
|
+
instanceKey,
|
|
380
|
+
runtime: adapter.runtime,
|
|
381
|
+
adapter,
|
|
382
|
+
config,
|
|
383
|
+
registry,
|
|
384
|
+
gateway,
|
|
385
|
+
ccPid,
|
|
386
|
+
tandemMembershipCount,
|
|
387
|
+
listTandemIds: () => listTandemIds(gateway),
|
|
388
|
+
toolCallHooks: { onTandemJoin, onTandemLeave },
|
|
389
|
+
onStreamReady: (handle) => {
|
|
390
|
+
activeStreamHandle = handle;
|
|
391
|
+
joinReconnect.notifyReady();
|
|
392
|
+
},
|
|
393
|
+
log: (line) => console.error(line)
|
|
394
|
+
});
|
|
395
|
+
server = started.server;
|
|
396
|
+
if (started.exitCleanup) {
|
|
397
|
+
const exitCleanup = started.exitCleanup;
|
|
398
|
+
process.on("exit", () => exitCleanup());
|
|
399
|
+
}
|
|
400
|
+
for (const step of started.teardown) teardownSteps.push(step);
|
|
401
|
+
} else {
|
|
402
|
+
server = createMcpServer(registry, adapter, tandemMembershipCount);
|
|
403
|
+
}
|
|
404
|
+
process.stdin.on("end", () => shutdown("stdin end"));
|
|
405
|
+
process.stdin.on("close", () => shutdown("stdin close"));
|
|
406
|
+
process.on("SIGHUP", () => shutdown("SIGHUP"));
|
|
407
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
408
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
409
|
+
if (ccPid !== null) {
|
|
410
|
+
const { createParentWatchdog } = await import("./parent-watchdog-TLU355FB.js");
|
|
411
|
+
const watchdog = createParentWatchdog({
|
|
412
|
+
ccPid,
|
|
413
|
+
onParentGone: () => shutdown("parent (Claude Code) gone")
|
|
414
|
+
});
|
|
415
|
+
watchdog.start();
|
|
416
|
+
teardownSteps.push(() => watchdog.stop());
|
|
417
|
+
} else {
|
|
418
|
+
console.error(
|
|
419
|
+
"[kojee-mcp] no Claude Code ancestor found \u2014 parent-liveness watchdog NOT armed (stdin/signal handlers still cover clean exits)"
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
await startMcpServer(server);
|
|
423
|
+
}
|
|
424
|
+
async function enrollAndDiscover(config, keystorePath, isRetry = false) {
|
|
425
|
+
const auth = new AuthModule(config.token, config.url, keystorePath);
|
|
426
|
+
const keyPair = await auth.ensureEnrolled();
|
|
427
|
+
const sessionId = GatewayClient.deriveSessionId(config.token);
|
|
428
|
+
console.error(`[kojee-mcp] Session: ${sessionId}`);
|
|
429
|
+
const gateway = new GatewayClient(
|
|
430
|
+
config.url,
|
|
431
|
+
config.token,
|
|
432
|
+
keyPair.privateKey,
|
|
433
|
+
keyPair.kid,
|
|
434
|
+
sessionId
|
|
435
|
+
);
|
|
436
|
+
const registry = new ToolRegistry(gateway);
|
|
437
|
+
try {
|
|
438
|
+
await registry.discoverTools();
|
|
439
|
+
return { registry, gateway };
|
|
440
|
+
} catch (err) {
|
|
441
|
+
if (isRetry || !isDPoPEnrollmentError(err)) {
|
|
442
|
+
throw err;
|
|
443
|
+
}
|
|
444
|
+
console.error(
|
|
445
|
+
"[kojee-mcp] Auth failed, attempting recovery with fresh enrollment..."
|
|
446
|
+
);
|
|
447
|
+
try {
|
|
448
|
+
if (fs3.existsSync(keystorePath)) fs3.unlinkSync(keystorePath);
|
|
449
|
+
} catch (unlinkErr) {
|
|
450
|
+
console.error("[kojee-mcp] Could not remove stale keystore:", unlinkErr);
|
|
451
|
+
}
|
|
452
|
+
return enrollAndDiscover(config, keystorePath, true);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export {
|
|
457
|
+
listTandemIds,
|
|
458
|
+
startProxy
|
|
459
|
+
};
|
|
@@ -2,9 +2,6 @@
|
|
|
2
2
|
function sanitizeChannelAttr(value) {
|
|
3
3
|
return String(value ?? "").replace(/["<>\u0000-\u001f\u007f]/g, "");
|
|
4
4
|
}
|
|
5
|
-
function defangChannelBody(content) {
|
|
6
|
-
return String(content ?? "").replace(/<(\/?)channel/gi, "<$1channel");
|
|
7
|
-
}
|
|
8
5
|
function formatChannelEvents(events) {
|
|
9
6
|
const header = `[${events.length} unread Tandem ${events.length === 1 ? "event" : "events"}]
|
|
10
7
|
|
|
@@ -12,7 +9,7 @@ function formatChannelEvents(events) {
|
|
|
12
9
|
const bodies = events.map((evt) => {
|
|
13
10
|
const attrs = Object.entries(evt.meta).map(([k, v]) => `${k}="${sanitizeChannelAttr(v)}"`).join(" ");
|
|
14
11
|
return `<channel source="kojee-mcp" ${attrs}>
|
|
15
|
-
${
|
|
12
|
+
${evt.content}
|
|
16
13
|
</channel>`;
|
|
17
14
|
});
|
|
18
15
|
return header + bodies.join("\n\n");
|