kojee-mcp 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -6
- package/dist/{chunk-36DMIXH7.js → chunk-BJMASMKX.js} +13 -23
- package/dist/chunk-E26AHU6J.js +27 -0
- package/dist/{chunk-E7TE4QZD.js → chunk-GBOTBYEP.js} +2 -1
- package/dist/{chunk-ZGVUM4AG.js → chunk-LCFCCWMM.js} +157 -257
- package/dist/{chunk-WHTH6WBP.js → chunk-LSUB6QMP.js} +3 -0
- package/dist/chunk-QB22PD6T.js +358 -0
- package/dist/chunk-VLZADEFC.js +247 -0
- package/dist/{chunk-VZVGTHGF.js → chunk-W6YRLSD4.js} +2 -1
- package/dist/cli.js +29 -11
- package/dist/doctor-GILTOH2R.js +222 -0
- package/dist/event-log-R6VW6GAF.js +17 -0
- package/dist/{hook-server-43QS7L7P.js → hook-server-QF5JVUHV.js} +28 -0
- package/dist/index.js +4 -2
- package/dist/{install-WV25CRU2.js → install-D2HIPOMT.js} +4 -3
- package/dist/{paired-config-OAR3O3XY.js → paired-config-RB4SABOS.js} +1 -1
- package/dist/resubscribe-SLZNA76S.js +59 -0
- package/dist/{session-discovery-WSHLR4OV.js → session-discovery-QE5TTAPS.js} +1 -1
- package/dist/stop-hook-VLQS6QPR.js +118 -0
- package/dist/tail-stream-UZ42UIWO.js +161 -0
- package/dist/{user-prompt-submit-hook-WSRIJVF4.js → user-prompt-submit-hook-C42DPDBO.js} +4 -4
- package/package.json +9 -7
- package/dist/event-log-ETWR6PPY.js +0 -112
- package/dist/stop-hook-5XU3EQAE.js +0 -76
package/dist/cli.js
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
AuthModule,
|
|
4
|
+
VERSION,
|
|
4
5
|
startProxy
|
|
5
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-LCFCCWMM.js";
|
|
7
|
+
import "./chunk-QB22PD6T.js";
|
|
8
|
+
import "./chunk-E26AHU6J.js";
|
|
9
|
+
import "./chunk-BJMASMKX.js";
|
|
6
10
|
import {
|
|
7
11
|
loadPairedConfig,
|
|
8
12
|
pairedConfigPath,
|
|
9
13
|
savePairedConfig
|
|
10
|
-
} from "./chunk-
|
|
11
|
-
import "./chunk-36DMIXH7.js";
|
|
14
|
+
} from "./chunk-GBOTBYEP.js";
|
|
12
15
|
|
|
13
16
|
// src/cli.ts
|
|
14
17
|
import { Command } from "commander";
|
|
15
18
|
import crypto from "crypto";
|
|
19
|
+
import os from "os";
|
|
16
20
|
import path from "path";
|
|
17
21
|
|
|
18
22
|
// src/tandem/pair.ts
|
|
@@ -55,7 +59,7 @@ async function runPair(opts) {
|
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
// src/cli.ts
|
|
58
|
-
var KOJEE_DIR = path.join(
|
|
62
|
+
var KOJEE_DIR = path.join(os.homedir(), ".kojee");
|
|
59
63
|
function deriveKeystorePath(token) {
|
|
60
64
|
const hash = crypto.createHash("sha256").update(token).digest("hex").slice(0, 12);
|
|
61
65
|
return path.join(KOJEE_DIR, `keypair-${hash}.json`);
|
|
@@ -65,7 +69,7 @@ function defaultPairedKeystorePath() {
|
|
|
65
69
|
}
|
|
66
70
|
var program = new Command().name("kojee-mcp").description(
|
|
67
71
|
"Local MCP proxy for Kojee \u2014 handles DPoP auth, tool discovery, and governance transparently"
|
|
68
|
-
).version(
|
|
72
|
+
).version(VERSION).enablePositionalOptions();
|
|
69
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) => {
|
|
70
74
|
const url = opts.url.replace(/\/+$/, "");
|
|
71
75
|
const keystorePath = opts.keystorePath ?? defaultPairedKeystorePath();
|
|
@@ -80,11 +84,11 @@ program.command("pair <code>").description("Pair this machine against Kojee usin
|
|
|
80
84
|
});
|
|
81
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) => {
|
|
82
86
|
if (opts.type === "stop") {
|
|
83
|
-
const { runStopHook } = await import("./stop-hook-
|
|
87
|
+
const { runStopHook } = await import("./stop-hook-VLQS6QPR.js");
|
|
84
88
|
await runStopHook();
|
|
85
89
|
process.exit(0);
|
|
86
90
|
} else if (opts.type === "user-prompt-submit") {
|
|
87
|
-
const { runUserPromptSubmitHook } = await import("./user-prompt-submit-hook-
|
|
91
|
+
const { runUserPromptSubmitHook } = await import("./user-prompt-submit-hook-C42DPDBO.js");
|
|
88
92
|
await runUserPromptSubmitHook();
|
|
89
93
|
process.exit(0);
|
|
90
94
|
} else {
|
|
@@ -93,7 +97,7 @@ program.command("hook").description("Run a kojee MCP hook script (called by Clau
|
|
|
93
97
|
}
|
|
94
98
|
});
|
|
95
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) => {
|
|
96
|
-
const { installHooks, uninstallHooks } = await import("./install-
|
|
100
|
+
const { installHooks, uninstallHooks } = await import("./install-D2HIPOMT.js");
|
|
97
101
|
if (opts.uninstall) {
|
|
98
102
|
const removed = uninstallHooks({ hooksPath: opts.hooksPath });
|
|
99
103
|
console.error(removed ? "Removed kojee hook entries." : "No kojee hook entries found.");
|
|
@@ -107,13 +111,27 @@ Restart Claude Code for hooks to take effect.`
|
|
|
107
111
|
);
|
|
108
112
|
}
|
|
109
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
|
+
});
|
|
110
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) => {
|
|
111
|
-
const { loadPairedConfig: loadPairedConfig2 } = await import("./paired-config-
|
|
129
|
+
const { loadPairedConfig: loadPairedConfig2 } = await import("./paired-config-RB4SABOS.js");
|
|
112
130
|
if (loadPairedConfig2() === null && !opts.uninstall) {
|
|
113
131
|
console.error("Not paired. Run `kojee-mcp pair <code> --url <broker>` first, then re-run `init`.");
|
|
114
132
|
process.exit(1);
|
|
115
133
|
}
|
|
116
|
-
const { runInit, runUninstall } = await import("./install-
|
|
134
|
+
const { runInit, runUninstall } = await import("./install-D2HIPOMT.js");
|
|
117
135
|
if (opts.uninstall) {
|
|
118
136
|
const report = runUninstall({ configPath: opts.configPath });
|
|
119
137
|
console.error(formatUninstall(report));
|
|
@@ -192,7 +210,7 @@ program.option("--token <token>", "Gateway token (for token-mode)").option("--ur
|
|
|
192
210
|
url = url.replace(/\/+$/, "");
|
|
193
211
|
keystorePath ??= deriveKeystorePath(token);
|
|
194
212
|
} else {
|
|
195
|
-
const { loadPairedConfig: loadPairedConfig2 } = await import("./paired-config-
|
|
213
|
+
const { loadPairedConfig: loadPairedConfig2 } = await import("./paired-config-RB4SABOS.js");
|
|
196
214
|
const cfg = loadPairedConfig2();
|
|
197
215
|
if (!cfg) {
|
|
198
216
|
console.error(
|
|
@@ -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
|
+
};
|
|
@@ -24,6 +24,9 @@ async function handleRequest(req, res, opts) {
|
|
|
24
24
|
if (req.method === "GET" && url.pathname === "/health") {
|
|
25
25
|
return json(res, 200, { ok: true });
|
|
26
26
|
}
|
|
27
|
+
if (req.method === "GET" && url.pathname === "/status") {
|
|
28
|
+
return respondWithStatus(res, opts);
|
|
29
|
+
}
|
|
27
30
|
if (req.method === "GET" && url.pathname === "/poll") {
|
|
28
31
|
const type = url.searchParams.get("type") ?? "";
|
|
29
32
|
const timeoutMs = Number.parseInt(url.searchParams.get("timeout_ms") ?? "0", 10);
|
|
@@ -42,6 +45,31 @@ function respondWithEvents(res, opts) {
|
|
|
42
45
|
const events = entries.map((entry) => opts.adapter.formatTandemEvent(entry.event));
|
|
43
46
|
json(res, 200, { events, count: events.length });
|
|
44
47
|
}
|
|
48
|
+
function respondWithStatus(res, opts) {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
if (!opts.getStreamState) {
|
|
51
|
+
return json(res, 200, { stream: "unknown", now });
|
|
52
|
+
}
|
|
53
|
+
const s = opts.getStreamState();
|
|
54
|
+
const lastEventAgeMs = s.lastEventAt === null ? null : now - s.lastEventAt;
|
|
55
|
+
const lastHeartbeatAgeMs = s.lastHeartbeatAt === null ? null : now - s.lastHeartbeatAt;
|
|
56
|
+
const stale = s.connected && s.staleAfterMs !== null && lastEventAgeMs !== null && lastEventAgeMs >= s.staleAfterMs;
|
|
57
|
+
json(res, 200, {
|
|
58
|
+
stream: s.connected ? "connected" : "disconnected",
|
|
59
|
+
connected: s.connected,
|
|
60
|
+
connectedSince: s.connectedSince,
|
|
61
|
+
lastEventAt: s.lastEventAt,
|
|
62
|
+
lastEventAgeMs,
|
|
63
|
+
lastHeartbeatAt: s.lastHeartbeatAt,
|
|
64
|
+
lastHeartbeatAgeMs,
|
|
65
|
+
reconnectCount: s.reconnectCount,
|
|
66
|
+
subscribedTandemCount: Object.keys(s.cursors).length,
|
|
67
|
+
cursors: s.cursors,
|
|
68
|
+
staleAfterMs: s.staleAfterMs,
|
|
69
|
+
stale,
|
|
70
|
+
now
|
|
71
|
+
});
|
|
72
|
+
}
|
|
45
73
|
async function longPollAndRespond(res, opts, timeoutMs) {
|
|
46
74
|
const immediate = opts.queue.takeForHook();
|
|
47
75
|
if (immediate.length > 0) {
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// src/hooks/install.ts
|
|
2
2
|
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
3
4
|
import path from "path";
|
|
4
5
|
function discoverInstallTargets() {
|
|
5
|
-
const home =
|
|
6
|
+
const home = os.homedir();
|
|
6
7
|
const targets = [];
|
|
7
8
|
const cliPath = path.join(home, ".claude.json");
|
|
8
9
|
const cliHooksPath = path.join(home, ".claude", "settings.json");
|
|
@@ -28,10 +29,10 @@ var MCP_SERVER_CMD = "npx";
|
|
|
28
29
|
var MCP_SERVER_ARGS = ["kojee-mcp"];
|
|
29
30
|
var MCP_SERVER_ENV = { KOJEE_RUNTIME: "claude-code" };
|
|
30
31
|
function defaultConfigPath() {
|
|
31
|
-
return path.join(
|
|
32
|
+
return path.join(os.homedir(), ".claude.json");
|
|
32
33
|
}
|
|
33
34
|
function defaultHooksPath() {
|
|
34
|
-
return path.join(
|
|
35
|
+
return path.join(os.homedir(), ".claude", "settings.json");
|
|
35
36
|
}
|
|
36
37
|
function deriveHooksPath(configPath) {
|
|
37
38
|
return path.join(path.dirname(configPath), ".claude", "settings.json");
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// src/tandem/resubscribe.ts
|
|
2
|
+
var DEFAULT_PER_CALL_TIMEOUT_MS = 1e4;
|
|
3
|
+
var DEFAULT_CONCURRENCY = 4;
|
|
4
|
+
var DEFAULT_DEBOUNCE_MS = 3e4;
|
|
5
|
+
async function resubscribeMemberships(opts) {
|
|
6
|
+
const { gateway, eventLog } = opts;
|
|
7
|
+
const perCallTimeoutMs = opts.perCallTimeoutMs ?? DEFAULT_PER_CALL_TIMEOUT_MS;
|
|
8
|
+
const concurrency = Math.max(1, opts.concurrency ?? DEFAULT_CONCURRENCY);
|
|
9
|
+
const now = opts.now ?? Date.now;
|
|
10
|
+
const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
11
|
+
if (opts.debounceState && now() - opts.debounceState.lastRunAt < debounceMs) {
|
|
12
|
+
return 0;
|
|
13
|
+
}
|
|
14
|
+
let tandemIds;
|
|
15
|
+
try {
|
|
16
|
+
tandemIds = opts.listTandems ? await opts.listTandems() : opts.tandemIds ?? [];
|
|
17
|
+
} catch (err) {
|
|
18
|
+
console.error("[resubscribe] re-list failed:", err.message);
|
|
19
|
+
tandemIds = [];
|
|
20
|
+
}
|
|
21
|
+
let touched = 0;
|
|
22
|
+
async function touchOne(tandemId) {
|
|
23
|
+
const ac = new AbortController();
|
|
24
|
+
const timer = setTimeout(() => ac.abort(), perCallTimeoutMs);
|
|
25
|
+
try {
|
|
26
|
+
const result = await gateway.sendRpc(
|
|
27
|
+
"tools/call",
|
|
28
|
+
{ name: "tandem_messages", arguments: { tandem_id: tandemId, limit: 1 } },
|
|
29
|
+
ac.signal
|
|
30
|
+
);
|
|
31
|
+
const maybeErr = result;
|
|
32
|
+
if (!maybeErr.isError) touched += 1;
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error(
|
|
35
|
+
`[resubscribe] touch failed for ${tandemId}:`,
|
|
36
|
+
err.message
|
|
37
|
+
);
|
|
38
|
+
} finally {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
let next = 0;
|
|
43
|
+
async function worker() {
|
|
44
|
+
while (next < tandemIds.length) {
|
|
45
|
+
const i = next++;
|
|
46
|
+
await touchOne(tandemIds[i]);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
await Promise.all(
|
|
50
|
+
Array.from({ length: Math.min(concurrency, tandemIds.length) }, () => worker())
|
|
51
|
+
);
|
|
52
|
+
if (opts.debounceState) opts.debounceState.lastRunAt = now();
|
|
53
|
+
await eventLog?.appendStatus(`status=subscribed n=${tandemIds.length} touched=${touched}`).catch(() => {
|
|
54
|
+
});
|
|
55
|
+
return touched;
|
|
56
|
+
}
|
|
57
|
+
export {
|
|
58
|
+
resubscribeMemberships
|
|
59
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readHookStdin
|
|
3
|
+
} from "./chunk-LSUB6QMP.js";
|
|
4
|
+
import {
|
|
5
|
+
buildMonitorNudge
|
|
6
|
+
} from "./chunk-E26AHU6J.js";
|
|
7
|
+
import {
|
|
8
|
+
deriveDiscoveryKey,
|
|
9
|
+
findClaudeAncestorPid
|
|
10
|
+
} from "./chunk-BJMASMKX.js";
|
|
11
|
+
import {
|
|
12
|
+
monitorHeartbeatPath,
|
|
13
|
+
nudgeSentinelPath
|
|
14
|
+
} from "./chunk-VLZADEFC.js";
|
|
15
|
+
import {
|
|
16
|
+
readSessionDiscoveryByKey
|
|
17
|
+
} from "./chunk-W6YRLSD4.js";
|
|
18
|
+
|
|
19
|
+
// src/hooks/stop-hook.ts
|
|
20
|
+
import fs from "fs";
|
|
21
|
+
var STOP_POLL_TIMEOUT_MS = Number.parseInt(
|
|
22
|
+
process.env["KOJEE_STOP_HOOK_TIMEOUT_MS"] ?? "0",
|
|
23
|
+
10
|
|
24
|
+
);
|
|
25
|
+
var MONITOR_HEARTBEAT_STALE_MS = 5e3;
|
|
26
|
+
var NUDGE_WINDOW_MS = Number.parseInt(
|
|
27
|
+
process.env["KOJEE_STOP_NUDGE_WINDOW_MS"] ?? "300000",
|
|
28
|
+
// 5 min
|
|
29
|
+
10
|
|
30
|
+
);
|
|
31
|
+
async function decideStopHook(deps) {
|
|
32
|
+
if (deps.stopHookActive) return "{}";
|
|
33
|
+
if (!deps.discovery) return "{}";
|
|
34
|
+
const body = await deps.pollEvents().catch(() => null);
|
|
35
|
+
if (body && body.count > 0) {
|
|
36
|
+
return JSON.stringify({ decision: "block", reason: formatEvents(body.events) });
|
|
37
|
+
}
|
|
38
|
+
const logPath = deps.discovery.eventLogPath;
|
|
39
|
+
if (logPath && deps.logHasContent(logPath) && !deps.monitorIsLive(logPath)) {
|
|
40
|
+
if (deps.nudgedRecently(logPath)) return "{}";
|
|
41
|
+
deps.recordNudge(logPath);
|
|
42
|
+
return JSON.stringify({ decision: "block", reason: buildMonitorNudge(logPath) });
|
|
43
|
+
}
|
|
44
|
+
return "{}";
|
|
45
|
+
}
|
|
46
|
+
function defaultNudgedRecently(logPath) {
|
|
47
|
+
try {
|
|
48
|
+
const { mtimeMs } = fs.statSync(nudgeSentinelPath(logPath));
|
|
49
|
+
return Date.now() - mtimeMs < NUDGE_WINDOW_MS;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function defaultRecordNudge(logPath) {
|
|
55
|
+
const sentinel = nudgeSentinelPath(logPath);
|
|
56
|
+
const now = /* @__PURE__ */ new Date();
|
|
57
|
+
try {
|
|
58
|
+
fs.utimesSync(sentinel, now, now);
|
|
59
|
+
} catch {
|
|
60
|
+
try {
|
|
61
|
+
fs.writeFileSync(sentinel, "", { mode: 384 });
|
|
62
|
+
} catch {
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function runStopHook() {
|
|
67
|
+
const { stopHookActive } = await readHookStdin();
|
|
68
|
+
const ccPid = await findClaudeAncestorPid();
|
|
69
|
+
const key = deriveDiscoveryKey(process.env["CLAUDE_PROJECT_DIR"], ccPid);
|
|
70
|
+
const discovery = readSessionDiscoveryByKey(key);
|
|
71
|
+
const out = await decideStopHook({
|
|
72
|
+
stopHookActive,
|
|
73
|
+
discovery: discovery ? { port: discovery.port, eventLogPath: discovery.eventLogPath } : null,
|
|
74
|
+
pollEvents: async () => {
|
|
75
|
+
const res = await fetch(
|
|
76
|
+
`http://127.0.0.1:${discovery.port}/poll?type=stop&timeout_ms=${STOP_POLL_TIMEOUT_MS}`
|
|
77
|
+
);
|
|
78
|
+
if (!res.ok) return { events: [], count: 0 };
|
|
79
|
+
return await res.json();
|
|
80
|
+
},
|
|
81
|
+
logHasContent,
|
|
82
|
+
monitorIsLive,
|
|
83
|
+
nudgedRecently: defaultNudgedRecently,
|
|
84
|
+
recordNudge: defaultRecordNudge
|
|
85
|
+
});
|
|
86
|
+
process.stdout.write(out);
|
|
87
|
+
}
|
|
88
|
+
function logHasContent(logPath) {
|
|
89
|
+
try {
|
|
90
|
+
return fs.statSync(logPath).size > 0;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function monitorIsLive(logPath) {
|
|
96
|
+
try {
|
|
97
|
+
const { mtimeMs } = fs.statSync(monitorHeartbeatPath(logPath));
|
|
98
|
+
return Date.now() - mtimeMs < MONITOR_HEARTBEAT_STALE_MS;
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function formatEvents(events) {
|
|
104
|
+
const header = `[${events.length} unread Tandem ${events.length === 1 ? "event" : "events"}]
|
|
105
|
+
|
|
106
|
+
`;
|
|
107
|
+
const bodies = events.map((evt) => {
|
|
108
|
+
const attrs = Object.entries(evt.meta).map(([k, v]) => `${k}="${v}"`).join(" ");
|
|
109
|
+
return `<channel source="kojee-mcp" ${attrs}>
|
|
110
|
+
${evt.content}
|
|
111
|
+
</channel>`;
|
|
112
|
+
});
|
|
113
|
+
return header + bodies.join("\n\n");
|
|
114
|
+
}
|
|
115
|
+
export {
|
|
116
|
+
decideStopHook,
|
|
117
|
+
runStopHook
|
|
118
|
+
};
|