kojee-mcp 0.2.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,143 @@
1
+ // src/tandem/session-discovery.ts
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ function sessionDiscoveryDir() {
6
+ return path.join(os.homedir(), ".kojee", "sessions");
7
+ }
8
+ function sessionDiscoveryPath(sessionId) {
9
+ return path.join(sessionDiscoveryDir(), `${sessionId}.json`);
10
+ }
11
+ function discoveryFileName(key) {
12
+ return `cc-${key}.json`;
13
+ }
14
+ function writeSessionDiscovery(sessionId, entry) {
15
+ const dir = sessionDiscoveryDir();
16
+ fs.mkdirSync(dir, { recursive: true, mode: 448 });
17
+ try {
18
+ fs.chmodSync(dir, 448);
19
+ } catch {
20
+ }
21
+ const filePath = sessionDiscoveryPath(sessionId);
22
+ fs.writeFileSync(filePath, JSON.stringify(entry, null, 2), { mode: 384 });
23
+ try {
24
+ fs.chmodSync(filePath, 384);
25
+ } catch {
26
+ }
27
+ }
28
+ function readSessionDiscovery(sessionId) {
29
+ try {
30
+ const raw = fs.readFileSync(sessionDiscoveryPath(sessionId), "utf8");
31
+ return JSON.parse(raw);
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+ function cleanupSessionDiscovery(sessionId) {
37
+ try {
38
+ fs.unlinkSync(sessionDiscoveryPath(sessionId));
39
+ } catch {
40
+ }
41
+ }
42
+ function discoveryPathForKey(key) {
43
+ return path.join(sessionDiscoveryDir(), discoveryFileName(key));
44
+ }
45
+ function writeDiscoveryByKey(key, entry) {
46
+ const dir = sessionDiscoveryDir();
47
+ fs.mkdirSync(dir, { recursive: true, mode: 448 });
48
+ try {
49
+ fs.chmodSync(dir, 448);
50
+ } catch {
51
+ }
52
+ const filePath = discoveryPathForKey(key);
53
+ fs.writeFileSync(filePath, JSON.stringify(entry, null, 2), { mode: 384 });
54
+ try {
55
+ fs.chmodSync(filePath, 384);
56
+ } catch {
57
+ }
58
+ }
59
+ function cleanupDiscoveryByKey(key) {
60
+ try {
61
+ fs.unlinkSync(discoveryPathForKey(key));
62
+ } catch {
63
+ }
64
+ }
65
+ function readSessionDiscoveryByKey(key) {
66
+ try {
67
+ const raw = fs.readFileSync(discoveryPathForKey(key), "utf8");
68
+ return JSON.parse(raw);
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+ var DEFAULT_SWEEP_MIN_AGE_MS = 6e4;
74
+ function isProcessAlive(pid) {
75
+ try {
76
+ process.kill(pid, 0);
77
+ return true;
78
+ } catch (err) {
79
+ if (err.code === "EPERM") return true;
80
+ return false;
81
+ }
82
+ }
83
+ function sweepStaleDiscovery(opts = {}) {
84
+ const minAgeMs = opts.minAgeMs ?? DEFAULT_SWEEP_MIN_AGE_MS;
85
+ const dir = sessionDiscoveryDir();
86
+ let entries;
87
+ try {
88
+ entries = fs.readdirSync(dir);
89
+ } catch {
90
+ return;
91
+ }
92
+ const now = Date.now();
93
+ for (const name of entries) {
94
+ if (!name.endsWith(".json")) continue;
95
+ const filePath = path.join(dir, name);
96
+ let stat;
97
+ try {
98
+ stat = fs.statSync(filePath);
99
+ } catch {
100
+ continue;
101
+ }
102
+ const ageMs = Math.max(0, now - stat.mtimeMs);
103
+ if (ageMs < minAgeMs) continue;
104
+ let parsed = null;
105
+ try {
106
+ parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
107
+ } catch {
108
+ try {
109
+ fs.unlinkSync(filePath);
110
+ } catch {
111
+ }
112
+ continue;
113
+ }
114
+ if (parsed?.schema !== 2) {
115
+ try {
116
+ fs.unlinkSync(filePath);
117
+ } catch {
118
+ }
119
+ continue;
120
+ }
121
+ if (typeof parsed.proxyPid !== "number" || !isProcessAlive(parsed.proxyPid)) {
122
+ try {
123
+ fs.unlinkSync(filePath);
124
+ } catch {
125
+ }
126
+ continue;
127
+ }
128
+ }
129
+ }
130
+
131
+ export {
132
+ sessionDiscoveryDir,
133
+ sessionDiscoveryPath,
134
+ discoveryFileName,
135
+ writeSessionDiscovery,
136
+ readSessionDiscovery,
137
+ cleanupSessionDiscovery,
138
+ discoveryPathForKey,
139
+ writeDiscoveryByKey,
140
+ cleanupDiscoveryByKey,
141
+ readSessionDiscoveryByKey,
142
+ sweepStaleDiscovery
143
+ };
package/dist/cli.js CHANGED
@@ -1,37 +1,229 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ AuthModule,
4
+ VERSION,
3
5
  startProxy
4
- } from "./chunk-QKAUM3TR.js";
6
+ } from "./chunk-LCFCCWMM.js";
7
+ import "./chunk-QB22PD6T.js";
8
+ import "./chunk-E26AHU6J.js";
9
+ import "./chunk-BJMASMKX.js";
10
+ import {
11
+ loadPairedConfig,
12
+ pairedConfigPath,
13
+ savePairedConfig
14
+ } from "./chunk-GBOTBYEP.js";
5
15
 
6
16
  // src/cli.ts
7
17
  import { Command } from "commander";
8
18
  import crypto from "crypto";
19
+ import os from "os";
9
20
  import path from "path";
10
- var KOJEE_DIR = path.join(process.env["HOME"] ?? "~", ".kojee");
21
+
22
+ // src/tandem/pair.ts
23
+ import fs from "fs";
24
+ async function runPair(opts) {
25
+ if (loadPairedConfig(opts.configPath) !== null) {
26
+ throw new Error(
27
+ `Already paired (config exists at ${opts.configPath}). To re-pair this slot, delete the config file first. For a second account, use --keystore-path /custom/keypair.json.`
28
+ );
29
+ }
30
+ try {
31
+ fs.unlinkSync(opts.keystorePath);
32
+ } catch {
33
+ }
34
+ const auth = new AuthModule(opts.code, opts.url, opts.keystorePath);
35
+ await auth.ensureEnrolled();
36
+ let principal_id;
37
+ let agent_id;
38
+ try {
39
+ const me = await fetch(`${opts.url}/api/v1/users/me/`, {
40
+ headers: { Authorization: `DPoP ${opts.code}` }
41
+ });
42
+ if (me.ok) {
43
+ const body = await me.json();
44
+ principal_id = body.principal_id;
45
+ agent_id = body.agent_id;
46
+ }
47
+ } catch {
48
+ }
49
+ const config = {
50
+ token: opts.code,
51
+ broker_url: opts.url,
52
+ paired_at: (/* @__PURE__ */ new Date()).toISOString(),
53
+ ...principal_id ? { principal_id } : {},
54
+ ...agent_id ? { agent_id } : {}
55
+ };
56
+ savePairedConfig(opts.configPath, config);
57
+ const who = principal_id && agent_id ? `${principal_id} (${agent_id})` : "(use kojee-mcp without args to start the proxy)";
58
+ return { message: `Paired as ${who}. Keypair: ${opts.keystorePath}. Config: ${opts.configPath}.` };
59
+ }
60
+
61
+ // src/cli.ts
62
+ var KOJEE_DIR = path.join(os.homedir(), ".kojee");
11
63
  function deriveKeystorePath(token) {
12
64
  const hash = crypto.createHash("sha256").update(token).digest("hex").slice(0, 12);
13
65
  return path.join(KOJEE_DIR, `keypair-${hash}.json`);
14
66
  }
67
+ function defaultPairedKeystorePath() {
68
+ return path.join(KOJEE_DIR, "keypair.json");
69
+ }
15
70
  var program = new Command().name("kojee-mcp").description(
16
71
  "Local MCP proxy for Kojee \u2014 handles DPoP auth, tool discovery, and governance transparently"
17
- ).version("0.2.2").requiredOption(
18
- "--token <token>",
19
- "Gateway token"
20
- ).requiredOption(
21
- "--url <url>",
22
- "Broker base URL (e.g. https://kojee.ai)"
23
- ).option(
72
+ ).version(VERSION).enablePositionalOptions();
73
+ program.command("pair <code>").description("Pair this machine against Kojee using a pair code from the dashboard").requiredOption("--url <url>", "Broker base URL (e.g. https://rosie-server.kojee.net)").option("--keystore-path <path>", "Path to keypair.json (default ~/.kojee/keypair.json)").action(async (code, opts) => {
74
+ const url = opts.url.replace(/\/+$/, "");
75
+ const keystorePath = opts.keystorePath ?? defaultPairedKeystorePath();
76
+ const configPath = opts.keystorePath ? path.join(path.dirname(opts.keystorePath), "config.json") : pairedConfigPath();
77
+ try {
78
+ const result = await runPair({ code, url, keystorePath, configPath });
79
+ console.error(result.message);
80
+ } catch (err) {
81
+ console.error("[kojee-mcp pair] Failed:", err.message);
82
+ process.exit(1);
83
+ }
84
+ });
85
+ program.command("hook").description("Run a kojee MCP hook script (called by Claude Code via ~/.claude/settings.json)").requiredOption("--type <type>", "Hook type: stop or user-prompt-submit").action(async (opts) => {
86
+ if (opts.type === "stop") {
87
+ const { runStopHook } = await import("./stop-hook-VLQS6QPR.js");
88
+ await runStopHook();
89
+ process.exit(0);
90
+ } else if (opts.type === "user-prompt-submit") {
91
+ const { runUserPromptSubmitHook } = await import("./user-prompt-submit-hook-C42DPDBO.js");
92
+ await runUserPromptSubmitHook();
93
+ process.exit(0);
94
+ } else {
95
+ console.error(`Unknown hook type: ${opts.type}. Expected 'stop' or 'user-prompt-submit'.`);
96
+ process.exit(1);
97
+ }
98
+ });
99
+ program.command("install-hooks").description("Install kojee Stop + UserPromptSubmit hooks in ~/.claude/settings.json (idempotent)").option("--hooks-path <path>", "Override default ~/.claude/settings.json").option("--uninstall", "Remove kojee hook entries instead of installing them").action(async (opts) => {
100
+ const { installHooks, uninstallHooks } = await import("./install-D2HIPOMT.js");
101
+ if (opts.uninstall) {
102
+ const removed = uninstallHooks({ hooksPath: opts.hooksPath });
103
+ console.error(removed ? "Removed kojee hook entries." : "No kojee hook entries found.");
104
+ } else {
105
+ const { stop, ups } = installHooks({ hooksPath: opts.hooksPath });
106
+ console.error(
107
+ "Installed kojee hooks in " + (opts.hooksPath ?? "~/.claude/settings.json") + `:
108
+ - Stop: ${stop === "added" ? "\u2713 added" : "\u21BB already installed"}
109
+ - UserPromptSubmit: ${ups === "added" ? "\u2713 added" : "\u21BB already installed"}
110
+ Restart Claude Code for hooks to take effect.`
111
+ );
112
+ }
113
+ });
114
+ program.command("tail <path>").description("Stream a file's contents and follow appends (portable replacement for `tail -F`)").action(async (filePath) => {
115
+ const { runTail } = await import("./tail-stream-UZ42UIWO.js");
116
+ try {
117
+ await runTail(filePath);
118
+ } catch (err) {
119
+ console.error("[kojee-mcp tail] Error:", err.message);
120
+ process.exit(1);
121
+ }
122
+ });
123
+ program.command("doctor").description("Diagnose the kojee wake path (proxy, hook-server, SSE stream, event log, Monitor) and print the exact wake recipe").action(async () => {
124
+ const { runDoctor } = await import("./doctor-GILTOH2R.js");
125
+ const code = await runDoctor();
126
+ process.exit(code);
127
+ });
128
+ program.command("init").description("Install kojee into Claude Code (MCP server entry + hooks). Run after `kojee-mcp pair`.").option("--config-path <path>", "Override default ~/.claude.json").option("--uninstall", "Remove the kojee MCP server entry and hooks").action(async (opts) => {
129
+ const { loadPairedConfig: loadPairedConfig2 } = await import("./paired-config-RB4SABOS.js");
130
+ if (loadPairedConfig2() === null && !opts.uninstall) {
131
+ console.error("Not paired. Run `kojee-mcp pair <code> --url <broker>` first, then re-run `init`.");
132
+ process.exit(1);
133
+ }
134
+ const { runInit, runUninstall } = await import("./install-D2HIPOMT.js");
135
+ if (opts.uninstall) {
136
+ const report = runUninstall({ configPath: opts.configPath });
137
+ console.error(formatUninstall(report));
138
+ } else {
139
+ const report = runInit({ configPath: opts.configPath });
140
+ console.error(formatInit(report));
141
+ }
142
+ });
143
+ function formatInit(report) {
144
+ const tick = (s) => {
145
+ if (s === "added") return "\u2713 added";
146
+ if (s === "already-installed") return "\u21BB already installed";
147
+ if (s === "preserved-different") return "\u26A0 preserved (existing entry differs \u2014 left untouched)";
148
+ if (s === "not-found") return "\u2014 not found";
149
+ return s ?? "\u2014";
150
+ };
151
+ const lines = ["Installing kojee for Claude:"];
152
+ for (const t of report.targets) {
153
+ const label = t.kind === "cli" ? "CLI" : "Claude.app";
154
+ lines.push("");
155
+ lines.push(` ${t.path} (${label})`);
156
+ lines.push(` mcpServers.kojee ${tick(t.mcpServer)}`);
157
+ if (t.kind === "cli") {
158
+ if (t.hooksPath) {
159
+ lines.push(` ${t.hooksPath} (hooks)`);
160
+ }
161
+ lines.push(` hooks.Stop ${tick(t.stopHook)}`);
162
+ lines.push(` hooks.UserPromptSubmit ${tick(t.userPromptSubmitHook)}`);
163
+ } else {
164
+ lines.push(` (hooks not applicable for Claude.app agent mode)`);
165
+ }
166
+ }
167
+ lines.push("");
168
+ const desktopWritten = report.targets.some((t) => t.kind === "desktop" && t.mcpServer === "added");
169
+ if (desktopWritten) {
170
+ lines.push(
171
+ "Existing Claude.app agent-mode sessions snapshotted the previous config",
172
+ "and won't pick up this change automatically. Start a NEW agent-mode",
173
+ "session (not a resumed one) to use the updated kojee config.",
174
+ ""
175
+ );
176
+ }
177
+ lines.push("To verify: in any new CC session, run /mcp and confirm `kojee` is listed.");
178
+ lines.push(" run /hooks and confirm both Stop and UserPromptSubmit show the kojee entries.");
179
+ lines.push("To remove: `kojee-mcp init --uninstall`");
180
+ return lines.join("\n");
181
+ }
182
+ function formatUninstall(report) {
183
+ const lines = ["Removing kojee from Claude:"];
184
+ for (const t of report.targets) {
185
+ const label = t.kind === "cli" ? "CLI" : "Claude.app";
186
+ lines.push("");
187
+ lines.push(` ${t.path} (${label})`);
188
+ lines.push(` mcpServers.kojee ${t.mcpServer ? "\u2713 removed" : "\u2014 not found"}`);
189
+ if (t.kind === "cli") {
190
+ if (t.hooksPath) {
191
+ lines.push(` ${t.hooksPath} (hooks)`);
192
+ }
193
+ lines.push(` hook entries ${t.hooks ? "\u2713 removed" : "\u2014 not found"}`);
194
+ }
195
+ }
196
+ return lines.join("\n");
197
+ }
198
+ program.option("--token <token>", "Gateway token (for token-mode)").option("--url <url>", "Broker base URL (for token-mode; required if --token is passed)").option(
24
199
  "--keystore-path <path>",
25
- "Path to persisted keypair file (defaults to per-token path under ~/.kojee/)"
200
+ "Path to keypair file (defaults to per-token path under ~/.kojee/)"
26
201
  ).action(async (opts) => {
27
- const url = opts.url.replace(/\/+$/, "");
28
- const keystorePath = opts.keystorePath ?? deriveKeystorePath(opts.token);
202
+ let token = opts.token;
203
+ let url = opts.url;
204
+ let keystorePath = opts.keystorePath;
205
+ if (token) {
206
+ if (!url) {
207
+ console.error("--url is required when --token is provided");
208
+ process.exit(1);
209
+ }
210
+ url = url.replace(/\/+$/, "");
211
+ keystorePath ??= deriveKeystorePath(token);
212
+ } else {
213
+ const { loadPairedConfig: loadPairedConfig2 } = await import("./paired-config-RB4SABOS.js");
214
+ const cfg = loadPairedConfig2();
215
+ if (!cfg) {
216
+ console.error(
217
+ "No --token provided and no ~/.kojee/config.json found. Run `kojee-mcp pair <code> --url <broker>` first."
218
+ );
219
+ process.exit(1);
220
+ }
221
+ token = cfg.token;
222
+ url = (opts.url ?? cfg.broker_url).replace(/\/+$/, "");
223
+ keystorePath ??= defaultPairedKeystorePath();
224
+ }
29
225
  try {
30
- await startProxy({
31
- token: opts.token,
32
- url,
33
- keystorePath
34
- });
226
+ await startProxy({ token, url, keystorePath });
35
227
  } catch (err) {
36
228
  console.error("[kojee-mcp] Fatal error:", err);
37
229
  process.exit(1);
@@ -0,0 +1,222 @@
1
+ import {
2
+ buildMonitorSpawn,
3
+ buildReplyRecipe
4
+ } from "./chunk-E26AHU6J.js";
5
+ import {
6
+ deriveDiscoveryKey,
7
+ findClaudeAncestorPid
8
+ } from "./chunk-BJMASMKX.js";
9
+ import {
10
+ monitorHeartbeatPath,
11
+ statusLogPath
12
+ } from "./chunk-VLZADEFC.js";
13
+ import {
14
+ discoveryPathForKey,
15
+ readSessionDiscoveryByKey
16
+ } from "./chunk-W6YRLSD4.js";
17
+ import {
18
+ loadPairedConfig
19
+ } from "./chunk-GBOTBYEP.js";
20
+
21
+ // src/doctor.ts
22
+ import fs from "fs";
23
+ function defaultIsPidAlive(pid) {
24
+ try {
25
+ process.kill(pid, 0);
26
+ return true;
27
+ } catch (err) {
28
+ if (err.code === "EPERM") return true;
29
+ return false;
30
+ }
31
+ }
32
+ function defaultStat(path) {
33
+ try {
34
+ const s = fs.statSync(path);
35
+ return { mtimeMs: s.mtimeMs, size: s.size };
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+ var STATUS_TAIL_BYTES = 64 * 1024;
41
+ function defaultReadLastStatusLine(path) {
42
+ let fd = null;
43
+ try {
44
+ const { size } = fs.statSync(path);
45
+ const start = Math.max(0, size - STATUS_TAIL_BYTES);
46
+ const len = size - start;
47
+ if (len === 0) return null;
48
+ fd = fs.openSync(path, "r");
49
+ const buf = Buffer.alloc(len);
50
+ fs.readSync(fd, buf, 0, len, start);
51
+ const text = buf.toString("utf8");
52
+ const statusLines = text.split("\n").filter((l) => l.includes("kojee-status"));
53
+ return statusLines.length > 0 ? statusLines[statusLines.length - 1] : null;
54
+ } catch {
55
+ return null;
56
+ } finally {
57
+ if (fd !== null) {
58
+ try {
59
+ fs.closeSync(fd);
60
+ } catch {
61
+ }
62
+ }
63
+ }
64
+ }
65
+ async function collectDoctorReport(deps = {}) {
66
+ const fetchFn = deps.fetchFn ?? fetch;
67
+ const isPidAlive = deps.isPidAlive ?? defaultIsPidAlive;
68
+ const statFile = deps.statFile ?? defaultStat;
69
+ const readLastStatusLine = deps.readLastStatusLine ?? defaultReadLastStatusLine;
70
+ const readDiscovery = deps.readDiscovery ?? readSessionDiscoveryByKey;
71
+ const projectDir = deps.projectDir ?? process.env["CLAUDE_PROJECT_DIR"];
72
+ const ccPid = deps.ccPid !== void 0 ? deps.ccPid : await findClaudeAncestorPid();
73
+ const discoveryKey = deriveDiscoveryKey(projectDir, ccPid);
74
+ const discoveryPath = discoveryPathForKey(discoveryKey);
75
+ const checks = [];
76
+ const paired = deps.pairedConfigPresent !== void 0 ? deps.pairedConfigPresent : loadPairedConfig() !== null;
77
+ checks.push({
78
+ name: "paired config",
79
+ ok: paired,
80
+ detail: paired ? "present" : "MISSING \u2014 run `kojee-mcp pair <code> --url <broker>`"
81
+ });
82
+ const discovery = readDiscovery(discoveryKey);
83
+ let logPath = null;
84
+ if (!discovery) {
85
+ checks.push({
86
+ name: "session discovery",
87
+ ok: false,
88
+ detail: `no discovery file at ${discoveryPath} (key=${discoveryKey}) \u2014 proxy not running for this session?`
89
+ });
90
+ } else {
91
+ logPath = discovery.eventLogPath ?? null;
92
+ const proxyPid = discovery.proxyPid ?? discovery.pid;
93
+ const alive = typeof proxyPid === "number" && isPidAlive(proxyPid);
94
+ checks.push({
95
+ name: "proxy process",
96
+ ok: alive,
97
+ detail: alive ? `alive (pid=${proxyPid})` : `DEAD (pid=${proxyPid ?? "?"}) \u2014 restart Claude Code`
98
+ });
99
+ const base = `http://127.0.0.1:${discovery.port}`;
100
+ const health = await probeJson(fetchFn, `${base}/health`);
101
+ checks.push({
102
+ name: "hook-server /health",
103
+ ok: health !== null,
104
+ detail: health !== null ? "ok" : "unreachable"
105
+ });
106
+ const statusProbe = await probeJsonWithStatus(fetchFn, `${base}/status`);
107
+ if (statusProbe.json === null && statusProbe.routeAbsent) {
108
+ checks.push({
109
+ name: "hook-server /status",
110
+ ok: "unknown",
111
+ detail: "proxy predates /status (old version) \u2014 stream state unknown; restart the session to upgrade"
112
+ });
113
+ } else if (statusProbe.json === null) {
114
+ checks.push({ name: "hook-server /status", ok: false, detail: "unreachable" });
115
+ } else {
116
+ const status = statusProbe.json;
117
+ const s = status;
118
+ const connected = s.connected === true;
119
+ const stale = s.stale === true;
120
+ const hbAge = s.lastHeartbeatAgeMs;
121
+ const hbStr = hbAge === null || hbAge === void 0 ? "no heartbeat yet" : `last heartbeat ${Math.round(hbAge / 1e3)}s ago`;
122
+ checks.push({
123
+ name: "SSE stream",
124
+ ok: connected && !stale ? true : connected ? "warn" : false,
125
+ detail: `${s.stream ?? "?"}; ${hbStr}; subscribed=${s.subscribedTandemCount ?? 0}; reconnects=${s.reconnectCount ?? 0}${stale ? " \u2014 STALE" : ""}`
126
+ });
127
+ }
128
+ }
129
+ if (logPath) {
130
+ const logStat = statFile(logPath);
131
+ if (!logStat) {
132
+ checks.push({ name: "messages log", ok: false, detail: `missing at ${logPath}` });
133
+ } else {
134
+ const ageMs = Date.now() - logStat.mtimeMs;
135
+ checks.push({
136
+ name: "messages log",
137
+ ok: true,
138
+ detail: `${logPath} (${logStat.size} bytes, mtime ${Math.round(ageMs / 1e3)}s ago)` + (logStat.size === 0 ? " \u2014 no Tandem messages yet (normal)" : "")
139
+ });
140
+ }
141
+ const statusPath = statusLogPath(logPath);
142
+ const statusStat = statFile(statusPath);
143
+ const lastStatus = readLastStatusLine(statusPath);
144
+ if (!statusStat) {
145
+ checks.push({ name: "status stream", ok: "warn", detail: `no status sibling at ${statusPath}` });
146
+ } else {
147
+ const sAge = Date.now() - statusStat.mtimeMs;
148
+ checks.push({
149
+ name: "status stream",
150
+ ok: true,
151
+ detail: `${statusPath} (${statusStat.size} bytes, mtime ${Math.round(sAge / 1e3)}s ago)` + (lastStatus ? `
152
+ last status: ${lastStatus.trim()}` : " \u2014 no status lines yet")
153
+ });
154
+ }
155
+ const sentinel = monitorHeartbeatPath(logPath);
156
+ const sentinelStat = statFile(sentinel);
157
+ const sentinelFresh = sentinelStat !== null && Date.now() - sentinelStat.mtimeMs < 5e3;
158
+ checks.push({
159
+ name: "Monitor (tail) heartbeat",
160
+ ok: sentinelFresh ? true : "warn",
161
+ detail: sentinelFresh ? "live (a Monitor is reading the log)" : "no fresh sentinel \u2014 spawn the Monitor (recipe below)"
162
+ });
163
+ }
164
+ checks.push({
165
+ name: "wire contract",
166
+ ok: "unknown",
167
+ detail: "fixture-vs-wire drift is NOT unit-testable \u2014 periodically reconcile the SSE wire `kind` enum (spec [tell,ask,ack,status] vs live [message,status,system]) against a live backend; see tests/fixtures/tandem-event.ts NOTE 7."
168
+ });
169
+ const recipe = logPath ? { monitorSpawn: buildMonitorSpawn(logPath), reply: buildReplyRecipe() } : null;
170
+ const verdict = checks.some((c) => c.ok === false) ? "broken" : checks.some((c) => c.ok === "warn") ? "degraded" : "healthy";
171
+ return { discoveryKey, discoveryPath, logPath, checks, recipe, verdict };
172
+ }
173
+ async function probeJson(fetchFn, url) {
174
+ try {
175
+ const res = await fetchFn(url);
176
+ if (!res.ok) return null;
177
+ return await res.json();
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+ async function probeJsonWithStatus(fetchFn, url) {
183
+ try {
184
+ const res = await fetchFn(url);
185
+ if (res.ok) return { json: await res.json(), routeAbsent: false };
186
+ const routeAbsent = res.status === 404 || res.status === 405;
187
+ return { json: null, routeAbsent };
188
+ } catch {
189
+ return { json: null, routeAbsent: false };
190
+ }
191
+ }
192
+ function formatDoctorReport(report) {
193
+ const mark = (ok) => ok === true ? "\u2713" : ok === "warn" ? "\u26A0" : ok === "unknown" ? "?" : "\u2717";
194
+ const lines = [];
195
+ lines.push(`kojee-mcp doctor \u2014 verdict: ${report.verdict.toUpperCase()}`);
196
+ lines.push("");
197
+ lines.push(` discovery key: ${report.discoveryKey}`);
198
+ for (const c of report.checks) {
199
+ lines.push(` ${mark(c.ok)} ${c.name}: ${c.detail}`);
200
+ }
201
+ lines.push("");
202
+ if (report.recipe) {
203
+ lines.push("Wake recipe (spawn once at session start):");
204
+ lines.push(` ${report.recipe.monitorSpawn}`);
205
+ lines.push(`Then ${report.recipe.reply}.`);
206
+ } else {
207
+ lines.push("No event-log path resolved \u2014 the proxy isn't running for this session.");
208
+ lines.push("Start Claude Code (which spawns the kojee proxy), then re-run doctor.");
209
+ }
210
+ return lines.join("\n");
211
+ }
212
+ async function runDoctor() {
213
+ const report = await collectDoctorReport();
214
+ console.error(formatDoctorReport(report));
215
+ return report.verdict === "broken" ? 1 : 0;
216
+ }
217
+ export {
218
+ collectDoctorReport,
219
+ defaultReadLastStatusLine,
220
+ formatDoctorReport,
221
+ runDoctor
222
+ };
@@ -0,0 +1,17 @@
1
+ import {
2
+ STATUS_LINE_PREFIX,
3
+ monitorHeartbeatPath,
4
+ nudgeSentinelPath,
5
+ startEventLog,
6
+ statusLogPath,
7
+ sweepStaleEventLogs
8
+ } from "./chunk-VLZADEFC.js";
9
+ import "./chunk-W6YRLSD4.js";
10
+ export {
11
+ STATUS_LINE_PREFIX,
12
+ monitorHeartbeatPath,
13
+ nudgeSentinelPath,
14
+ startEventLog,
15
+ statusLogPath,
16
+ sweepStaleEventLogs
17
+ };
@@ -0,0 +1,43 @@
1
+ // src/tandem/event-queue.ts
2
+ var EventQueue = class {
3
+ entries = [];
4
+ capacity;
5
+ maxAgeMs;
6
+ constructor(opts = {}) {
7
+ this.capacity = opts.capacity ?? 200;
8
+ this.maxAgeMs = opts.maxAgeMs ?? 10 * 6e4;
9
+ }
10
+ push(event) {
11
+ const now = Date.now();
12
+ this.entries = this.entries.filter((e) => now - e.receivedAt <= this.maxAgeMs);
13
+ this.entries.push({
14
+ event,
15
+ receivedAt: now,
16
+ deliveredViaChannel: false,
17
+ deliveredViaMonitor: false,
18
+ deliveredViaHook: false
19
+ });
20
+ while (this.entries.length > this.capacity) this.entries.shift();
21
+ }
22
+ markChannelDelivered(eventId) {
23
+ const entry = this.entries.find((e) => e.event.id === eventId);
24
+ if (entry) entry.deliveredViaChannel = true;
25
+ }
26
+ markMonitorDelivered(eventId) {
27
+ const entry = this.entries.find((e) => e.event.id === eventId);
28
+ if (entry) entry.deliveredViaMonitor = true;
29
+ }
30
+ takeForHook() {
31
+ const eligible = this.entries.filter(
32
+ (e) => !e.deliveredViaChannel && !e.deliveredViaMonitor && !e.deliveredViaHook
33
+ );
34
+ for (const e of eligible) e.deliveredViaHook = true;
35
+ return eligible;
36
+ }
37
+ snapshot() {
38
+ return [...this.entries];
39
+ }
40
+ };
41
+ export {
42
+ EventQueue
43
+ };