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.
Files changed (35) hide show
  1. package/README.md +24 -0
  2. package/dist/ancestry-ONFBQEP5.js +8 -0
  3. package/dist/{codex-stop-hook-SWA53ECG.js → chunk-35XBRG3V.js} +4 -3
  4. package/dist/{chunk-MKDMAAMN.js → chunk-5XP2UOFK.js} +12 -0
  5. package/dist/{chunk-DS26OORG.js → chunk-CO73VGWM.js} +41 -23
  6. package/dist/chunk-FQZCENSG.js +459 -0
  7. package/dist/{chunk-YKS6YZKM.js → chunk-PHXO5P25.js} +1 -4
  8. package/dist/chunk-WLMPCX7T.js +116 -0
  9. package/dist/chunk-XLKGPGZT.js +0 -0
  10. package/dist/chunk-XXFVWP6H.js +44 -0
  11. package/dist/{ensure-join-7AEDJMPE.js → chunk-YKW54DKF.js} +45 -15
  12. package/dist/cli.js +16 -13
  13. package/dist/codex-stop-hook-VY7DOMAG.js +16 -0
  14. package/dist/{doctor-XK335W7B.js → doctor-FVTALRQD.js} +110 -15
  15. package/dist/ensure-join-5Y5IJ7HN.js +8 -0
  16. package/dist/{event-log-B27VVEMK.js → event-log-VZD7NKYX.js} +1 -1
  17. package/dist/event-stream-FOT7MJZH.js +19 -0
  18. package/dist/{gateway-client-93P1E0CZ.d.ts → gateway-client-C6yx1mfM.d.ts} +6 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.js +7 -5
  21. package/dist/lib.d.ts +181 -3
  22. package/dist/lib.js +7 -7
  23. package/dist/{parent-watchdog-RZLHYP7T.js → parent-watchdog-TLU355FB.js} +1 -1
  24. package/dist/registry-A3VT6VJD.js +348 -0
  25. package/dist/server-77QRWKJM.js +14 -0
  26. package/dist/{stop-hook-OTCJGL6V.js → stop-hook-CUVDKXP7.js} +8 -7
  27. package/dist/{tail-stream-JNR4WFW3.js → tail-stream-VZ462ZON.js} +3 -2
  28. package/dist/{user-prompt-submit-hook-QXMC7EZU.js → user-prompt-submit-hook-PMBUPKUV.js} +6 -6
  29. package/dist/{webhook-sink-NWGCUDGY.js → webhook-sink-N6AUTFL3.js} +1 -1
  30. package/package.json +1 -1
  31. package/dist/chunk-SCDWPGH3.js +0 -637
  32. package/dist/{chunk-BJMASMKX.js → chunk-VHKPWUX7.js} +0 -0
  33. package/dist/{doctor-codex-SMROUYGV.js → doctor-codex-PA3WO6LR.js} +1 -1
  34. package/dist/{send-cli-CN5EX7PO.js → send-cli-NZP5XE7T.js} +5 -5
  35. 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
@@ -0,0 +1,8 @@
1
+ import {
2
+ deriveDiscoveryKey,
3
+ findClaudeAncestorPid
4
+ } from "./chunk-VHKPWUX7.js";
5
+ export {
6
+ deriveDiscoveryKey,
7
+ findClaudeAncestorPid
8
+ };
@@ -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/tandem/event-log.ts
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
- if (typeof data.pid === "number" && isProcessAlive(data.pid)) {
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
- try {
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
- fs.unlinkSync(filePath);
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, "&lt;$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
- ${defangChannelBody(evt.content)}
12
+ ${evt.content}
16
13
  </channel>`;
17
14
  });
18
15
  return header + bodies.join("\n\n");