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,99 @@
1
+ // src/tandem/hook-server.ts
2
+ import { createServer } from "http";
3
+ async function startHookServer(opts) {
4
+ const server = createServer((req, res) => {
5
+ handleRequest(req, res, opts).catch((err) => {
6
+ console.error("[hook-server] error:", err);
7
+ if (!res.headersSent) {
8
+ res.writeHead(500, { "Content-Type": "application/json" });
9
+ res.end(JSON.stringify({ error: "internal_error" }));
10
+ }
11
+ });
12
+ });
13
+ await new Promise((resolve) => server.listen(opts.port ?? 0, "127.0.0.1", resolve));
14
+ const addr = server.address();
15
+ return {
16
+ port: addr.port,
17
+ stop: () => new Promise((resolve, reject) => {
18
+ server.close((err) => err ? reject(err) : resolve());
19
+ })
20
+ };
21
+ }
22
+ async function handleRequest(req, res, opts) {
23
+ const url = new URL(req.url ?? "/", "http://x");
24
+ if (req.method === "GET" && url.pathname === "/health") {
25
+ return json(res, 200, { ok: true });
26
+ }
27
+ if (req.method === "GET" && url.pathname === "/status") {
28
+ return respondWithStatus(res, opts);
29
+ }
30
+ if (req.method === "GET" && url.pathname === "/poll") {
31
+ const type = url.searchParams.get("type") ?? "";
32
+ const timeoutMs = Number.parseInt(url.searchParams.get("timeout_ms") ?? "0", 10);
33
+ if (type === "user-prompt-submit") {
34
+ return respondWithEvents(res, opts);
35
+ }
36
+ if (type === "stop") {
37
+ return longPollAndRespond(res, opts, Math.max(0, timeoutMs));
38
+ }
39
+ return json(res, 400, { error: "unknown type", detail: "type must be 'stop' or 'user-prompt-submit'" });
40
+ }
41
+ return json(res, 404, { error: "not_found", detail: `${req.method} ${url.pathname}` });
42
+ }
43
+ function respondWithEvents(res, opts) {
44
+ const entries = opts.queue.takeForHook();
45
+ const events = entries.map((entry) => opts.adapter.formatTandemEvent(entry.event));
46
+ json(res, 200, { events, count: events.length });
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
+ }
73
+ async function longPollAndRespond(res, opts, timeoutMs) {
74
+ const immediate = opts.queue.takeForHook();
75
+ if (immediate.length > 0) {
76
+ const events = immediate.map((e) => opts.adapter.formatTandemEvent(e.event));
77
+ return json(res, 200, { events, count: events.length });
78
+ }
79
+ const deadline = Date.now() + timeoutMs;
80
+ while (Date.now() < deadline) {
81
+ await sleep(100);
82
+ const batch = opts.queue.takeForHook();
83
+ if (batch.length > 0) {
84
+ const events = batch.map((e) => opts.adapter.formatTandemEvent(e.event));
85
+ return json(res, 200, { events, count: events.length });
86
+ }
87
+ }
88
+ json(res, 200, { events: [], count: 0 });
89
+ }
90
+ function json(res, status, body) {
91
+ res.writeHead(status, { "Content-Type": "application/json" });
92
+ res.end(JSON.stringify(body));
93
+ }
94
+ function sleep(ms) {
95
+ return new Promise((r) => setTimeout(r, ms));
96
+ }
97
+ export {
98
+ startHookServer
99
+ };
package/dist/index.d.ts CHANGED
@@ -4,19 +4,6 @@ interface ProxyConfig {
4
4
  keystorePath: string;
5
5
  }
6
6
 
7
- /**
8
- * Bootstrap and start the Kojee MCP proxy.
9
- *
10
- * Startup sequence:
11
- * 1. Enroll keypair (or load existing)
12
- * 2. Derive session ID
13
- * 3. Create gateway client
14
- * 4. Discover tools from gateway
15
- * 5. Start MCP stdio server
16
- *
17
- * If step 4 fails with an auth error that looks like a stale keystore,
18
- * we wipe the keystore and retry once with a fresh enrollment.
19
- */
20
7
  declare function startProxy(config: ProxyConfig): Promise<void>;
21
8
 
22
9
  export { type ProxyConfig, startProxy };
package/dist/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import {
2
2
  startProxy
3
- } from "./chunk-QKAUM3TR.js";
3
+ } from "./chunk-LCFCCWMM.js";
4
+ import "./chunk-QB22PD6T.js";
5
+ import "./chunk-E26AHU6J.js";
6
+ import "./chunk-BJMASMKX.js";
4
7
  export {
5
8
  startProxy
6
9
  };
@@ -0,0 +1,183 @@
1
+ // src/hooks/install.ts
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ function discoverInstallTargets() {
6
+ const home = os.homedir();
7
+ const targets = [];
8
+ const cliPath = path.join(home, ".claude.json");
9
+ const cliHooksPath = path.join(home, ".claude", "settings.json");
10
+ targets.push({ kind: "cli", path: cliPath, exists: fs.existsSync(cliPath), hooksPath: cliHooksPath });
11
+ let desktopPath = null;
12
+ if (process.platform === "darwin") {
13
+ desktopPath = path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
14
+ } else if (process.platform === "win32") {
15
+ const appData = process.env["APPDATA"] ?? path.join(home, "AppData", "Roaming");
16
+ desktopPath = path.join(appData, "Claude", "claude_desktop_config.json");
17
+ } else if (process.platform === "linux") {
18
+ const xdgConfig = process.env["XDG_CONFIG_HOME"] ?? path.join(home, ".config");
19
+ desktopPath = path.join(xdgConfig, "Claude", "claude_desktop_config.json");
20
+ }
21
+ if (desktopPath) {
22
+ targets.push({ kind: "desktop", path: desktopPath, exists: fs.existsSync(desktopPath) });
23
+ }
24
+ return targets;
25
+ }
26
+ var STOP_COMMAND = "npx -y kojee-mcp hook --type=stop";
27
+ var UPS_COMMAND = "npx -y kojee-mcp hook --type=user-prompt-submit";
28
+ var MCP_SERVER_CMD = "npx";
29
+ var MCP_SERVER_ARGS = ["kojee-mcp"];
30
+ var MCP_SERVER_ENV = { KOJEE_RUNTIME: "claude-code" };
31
+ function defaultConfigPath() {
32
+ return path.join(os.homedir(), ".claude.json");
33
+ }
34
+ function defaultHooksPath() {
35
+ return path.join(os.homedir(), ".claude", "settings.json");
36
+ }
37
+ function deriveHooksPath(configPath) {
38
+ return path.join(path.dirname(configPath), ".claude", "settings.json");
39
+ }
40
+ function readConfig(p) {
41
+ try {
42
+ return JSON.parse(fs.readFileSync(p, "utf8"));
43
+ } catch {
44
+ return {};
45
+ }
46
+ }
47
+ function writeConfig(p, cfg) {
48
+ const dir = path.dirname(p);
49
+ fs.mkdirSync(dir, { recursive: true });
50
+ fs.writeFileSync(p, JSON.stringify(cfg, null, 2), { mode: 384 });
51
+ try {
52
+ fs.chmodSync(p, 384);
53
+ } catch {
54
+ }
55
+ }
56
+ function hasKojeeEntry(arr, command) {
57
+ if (!arr) return false;
58
+ return arr.some((e) => e.hooks.some((h) => h.command === command));
59
+ }
60
+ function installHookEntry(cfg, event, command) {
61
+ cfg.hooks ??= {};
62
+ cfg.hooks[event] ??= [];
63
+ if (hasKojeeEntry(cfg.hooks[event], command)) return "already-installed";
64
+ cfg.hooks[event].push({ hooks: [{ type: "command", command }] });
65
+ return "added";
66
+ }
67
+ function uninstallHookEntries(cfg) {
68
+ let removed = false;
69
+ if (!cfg.hooks) return false;
70
+ for (const event of ["Stop", "UserPromptSubmit"]) {
71
+ const arr = cfg.hooks[event];
72
+ if (!arr) continue;
73
+ const before = arr.length;
74
+ cfg.hooks[event] = arr.filter(
75
+ (entry) => !entry.hooks.some((h) => h.command.startsWith("npx -y kojee-mcp hook"))
76
+ );
77
+ if (cfg.hooks[event].length !== before) removed = true;
78
+ }
79
+ return removed;
80
+ }
81
+ function installHooks(opts = {}) {
82
+ const p = opts.hooksPath ?? (opts.configPath ? deriveHooksPath(opts.configPath) : defaultHooksPath());
83
+ const cfg = readConfig(p);
84
+ const stop = installHookEntry(cfg, "Stop", STOP_COMMAND);
85
+ const ups = installHookEntry(cfg, "UserPromptSubmit", UPS_COMMAND);
86
+ writeConfig(p, cfg);
87
+ return { stop, ups };
88
+ }
89
+ function uninstallHooks(opts = {}) {
90
+ const p = opts.hooksPath ?? (opts.configPath ? deriveHooksPath(opts.configPath) : defaultHooksPath());
91
+ const cfg = readConfig(p);
92
+ const removed = uninstallHookEntries(cfg);
93
+ writeConfig(p, cfg);
94
+ return removed;
95
+ }
96
+ function installMcpServer(opts = {}) {
97
+ const p = opts.configPath ?? defaultConfigPath();
98
+ const cfg = readConfig(p);
99
+ cfg.mcpServers ??= {};
100
+ const existing = cfg.mcpServers["kojee"];
101
+ if (existing) {
102
+ const sameCommand = existing.command === MCP_SERVER_CMD;
103
+ const sameArgs = JSON.stringify(existing.args ?? []) === JSON.stringify(MCP_SERVER_ARGS);
104
+ const sameEnv = JSON.stringify(existing.env ?? {}) === JSON.stringify(MCP_SERVER_ENV);
105
+ if (sameCommand && sameArgs && sameEnv) return "already-installed";
106
+ return "preserved-different";
107
+ }
108
+ cfg.mcpServers["kojee"] = {
109
+ command: MCP_SERVER_CMD,
110
+ args: [...MCP_SERVER_ARGS],
111
+ env: { ...MCP_SERVER_ENV }
112
+ };
113
+ writeConfig(p, cfg);
114
+ return "added";
115
+ }
116
+ function uninstallMcpServer(opts = {}) {
117
+ const p = opts.configPath ?? defaultConfigPath();
118
+ const cfg = readConfig(p);
119
+ if (!cfg.mcpServers || !cfg.mcpServers["kojee"]) return false;
120
+ delete cfg.mcpServers["kojee"];
121
+ writeConfig(p, cfg);
122
+ return true;
123
+ }
124
+ function runInit(opts = {}) {
125
+ const targets = opts.configPath ? [{
126
+ kind: "cli",
127
+ path: opts.configPath,
128
+ exists: true,
129
+ hooksPath: opts.hooksPath ?? deriveHooksPath(opts.configPath)
130
+ }] : discoverInstallTargets();
131
+ const reports = [];
132
+ for (const t of targets) {
133
+ if (!t.exists) {
134
+ reports.push({ kind: t.kind, path: t.path, ...t.hooksPath ? { hooksPath: t.hooksPath } : {}, mcpServer: "not-found" });
135
+ continue;
136
+ }
137
+ const mcpServer = installMcpServer({ configPath: t.path });
138
+ let stopHook;
139
+ let upsHook;
140
+ if (t.kind === "cli") {
141
+ const hookReport = installHooks({ hooksPath: t.hooksPath ?? deriveHooksPath(t.path) });
142
+ stopHook = hookReport.stop;
143
+ upsHook = hookReport.ups;
144
+ }
145
+ reports.push({
146
+ kind: t.kind,
147
+ path: t.path,
148
+ ...t.hooksPath ? { hooksPath: t.hooksPath } : {},
149
+ mcpServer,
150
+ ...stopHook ? { stopHook } : {},
151
+ ...upsHook ? { userPromptSubmitHook: upsHook } : {}
152
+ });
153
+ }
154
+ return { targets: reports, configPath: targets[0]?.path };
155
+ }
156
+ function runUninstall(opts = {}) {
157
+ const targets = opts.configPath ? [{
158
+ kind: "cli",
159
+ path: opts.configPath,
160
+ exists: true,
161
+ hooksPath: opts.hooksPath ?? deriveHooksPath(opts.configPath)
162
+ }] : discoverInstallTargets();
163
+ const reports = [];
164
+ for (const t of targets) {
165
+ if (!t.exists) {
166
+ reports.push({ kind: t.kind, path: t.path, ...t.hooksPath ? { hooksPath: t.hooksPath } : {}, mcpServer: false, hooks: false });
167
+ continue;
168
+ }
169
+ const mcpServer = uninstallMcpServer({ configPath: t.path });
170
+ const hooks = t.kind === "cli" ? uninstallHooks({ hooksPath: t.hooksPath ?? deriveHooksPath(t.path) }) : false;
171
+ reports.push({ kind: t.kind, path: t.path, ...t.hooksPath ? { hooksPath: t.hooksPath } : {}, mcpServer, hooks });
172
+ }
173
+ return { targets: reports, configPath: targets[0]?.path };
174
+ }
175
+ export {
176
+ discoverInstallTargets,
177
+ installHooks,
178
+ installMcpServer,
179
+ runInit,
180
+ runUninstall,
181
+ uninstallHooks,
182
+ uninstallMcpServer
183
+ };
@@ -0,0 +1,10 @@
1
+ import {
2
+ loadPairedConfig,
3
+ pairedConfigPath,
4
+ savePairedConfig
5
+ } from "./chunk-GBOTBYEP.js";
6
+ export {
7
+ loadPairedConfig,
8
+ pairedConfigPath,
9
+ savePairedConfig
10
+ };
@@ -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,26 @@
1
+ import {
2
+ cleanupDiscoveryByKey,
3
+ cleanupSessionDiscovery,
4
+ discoveryFileName,
5
+ discoveryPathForKey,
6
+ readSessionDiscovery,
7
+ readSessionDiscoveryByKey,
8
+ sessionDiscoveryDir,
9
+ sessionDiscoveryPath,
10
+ sweepStaleDiscovery,
11
+ writeDiscoveryByKey,
12
+ writeSessionDiscovery
13
+ } from "./chunk-W6YRLSD4.js";
14
+ export {
15
+ cleanupDiscoveryByKey,
16
+ cleanupSessionDiscovery,
17
+ discoveryFileName,
18
+ discoveryPathForKey,
19
+ readSessionDiscovery,
20
+ readSessionDiscoveryByKey,
21
+ sessionDiscoveryDir,
22
+ sessionDiscoveryPath,
23
+ sweepStaleDiscovery,
24
+ writeDiscoveryByKey,
25
+ writeSessionDiscovery
26
+ };
@@ -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
+ };
@@ -0,0 +1,161 @@
1
+ import {
2
+ createAdaptiveWatchdog
3
+ } from "./chunk-QB22PD6T.js";
4
+ import {
5
+ STATUS_LINE_PREFIX,
6
+ monitorHeartbeatPath,
7
+ statusLogPath
8
+ } from "./chunk-VLZADEFC.js";
9
+ import "./chunk-W6YRLSD4.js";
10
+
11
+ // src/tail-stream.ts
12
+ import fs from "fs";
13
+ var STATUS_LINE_RE = new RegExp(`^\\[[^\\]]*\\] ${STATUS_LINE_PREFIX} (.*)$`);
14
+ var HEARTBEAT_FIELDS_RE = /(^|\s)status=heartbeat(\s|$)/;
15
+ var HEARTBEAT_STALE_FLOOR_MS = envInt("KOJEE_TAIL_STALE_MS", 12e4);
16
+ var STALE_CHECK_INTERVAL_MS = envInt("KOJEE_TAIL_CHECK_MS", 5e3);
17
+ function envInt(name, fallback) {
18
+ const v = Number.parseInt(process.env[name] ?? "", 10);
19
+ return Number.isFinite(v) && v > 0 ? v : fallback;
20
+ }
21
+ async function runTail(messagesPath) {
22
+ const statusPath = statusLogPath(messagesPath);
23
+ let lastLifeAt = Date.now();
24
+ const watchdog = createAdaptiveWatchdog({ floorMs: HEARTBEAT_STALE_FLOOR_MS });
25
+ let staleAnnounced = false;
26
+ const heartbeatPath = monitorHeartbeatPath(messagesPath);
27
+ const touchHeartbeat = () => {
28
+ const now = /* @__PURE__ */ new Date();
29
+ try {
30
+ fs.utimesSync(heartbeatPath, now, now);
31
+ } catch {
32
+ try {
33
+ fs.writeFileSync(heartbeatPath, "", { mode: 384 });
34
+ } catch {
35
+ }
36
+ }
37
+ };
38
+ function onMessageLine(line) {
39
+ lastLifeAt = Date.now();
40
+ if (staleAnnounced) {
41
+ staleAnnounced = false;
42
+ process.stdout.write("[kojee] stream recovered \u2014 events flowing again.\n");
43
+ }
44
+ process.stdout.write(line + "\n");
45
+ }
46
+ function onStatusLine(line) {
47
+ const now = Date.now();
48
+ const m = STATUS_LINE_RE.exec(line);
49
+ const fields = m ? m[1] ?? "" : "";
50
+ if (HEARTBEAT_FIELDS_RE.test(fields)) {
51
+ watchdog.onHeartbeat(now);
52
+ }
53
+ lastLifeAt = now;
54
+ if (staleAnnounced) {
55
+ staleAnnounced = false;
56
+ process.stdout.write("[kojee] stream recovered \u2014 heartbeats resumed.\n");
57
+ }
58
+ }
59
+ const followers = [
60
+ makeFollower(messagesPath, onMessageLine, touchHeartbeat),
61
+ makeFollower(statusPath, onStatusLine, touchHeartbeat)
62
+ ];
63
+ void Promise.all(followers.map((f) => f.start()));
64
+ const staleTimer = setInterval(() => {
65
+ if (staleAnnounced) return;
66
+ const now = Date.now();
67
+ if (!watchdog.shouldAbort(lastLifeAt, now)) return;
68
+ staleAnnounced = true;
69
+ const silentMs = now - lastLifeAt;
70
+ process.stdout.write(
71
+ `[kojee] WARNING: no events or heartbeat for ${Math.round(silentMs / 1e3)}s (learned cadence) \u2014 stream may be dead; run \`npx kojee-mcp doctor\`.
72
+ `
73
+ );
74
+ }, STALE_CHECK_INTERVAL_MS);
75
+ await new Promise((resolve) => {
76
+ const cleanup = () => {
77
+ clearInterval(staleTimer);
78
+ for (const f of followers) f.stop();
79
+ try {
80
+ fs.unlinkSync(heartbeatPath);
81
+ } catch {
82
+ }
83
+ resolve();
84
+ };
85
+ process.on("SIGTERM", cleanup);
86
+ process.on("SIGINT", cleanup);
87
+ });
88
+ }
89
+ function makeFollower(filePath, onLine, onActivity) {
90
+ let buffer = "";
91
+ let offset = 0;
92
+ let watcher = null;
93
+ let pollTimer = null;
94
+ let stopped = false;
95
+ async function _drain() {
96
+ let stat;
97
+ try {
98
+ stat = await fs.promises.stat(filePath);
99
+ } catch {
100
+ return;
101
+ }
102
+ if (stat.size < offset) {
103
+ buffer = "";
104
+ offset = 0;
105
+ }
106
+ if (stat.size === offset) return;
107
+ const fh = await fs.promises.open(filePath, "r");
108
+ try {
109
+ const len = stat.size - offset;
110
+ const buf = Buffer.alloc(len);
111
+ await fh.read(buf, 0, len, offset);
112
+ buffer += buf.toString("utf8");
113
+ offset = stat.size;
114
+ let nl;
115
+ while ((nl = buffer.indexOf("\n")) !== -1) {
116
+ const line = buffer.slice(0, nl);
117
+ buffer = buffer.slice(nl + 1);
118
+ onLine(line);
119
+ }
120
+ } finally {
121
+ await fh.close();
122
+ }
123
+ }
124
+ let draining = Promise.resolve();
125
+ const scheduleDrain = () => {
126
+ draining = draining.then(_drain, _drain);
127
+ return draining;
128
+ };
129
+ return {
130
+ async start() {
131
+ while (!stopped && !fs.existsSync(filePath)) {
132
+ await sleep(250);
133
+ }
134
+ if (stopped) return;
135
+ onActivity();
136
+ await scheduleDrain();
137
+ watcher = fs.watch(filePath, { persistent: true });
138
+ watcher.on("change", () => {
139
+ onActivity();
140
+ void scheduleDrain();
141
+ });
142
+ watcher.on("error", () => {
143
+ });
144
+ pollTimer = setInterval(() => {
145
+ onActivity();
146
+ void scheduleDrain();
147
+ }, 1e3);
148
+ },
149
+ stop() {
150
+ stopped = true;
151
+ if (pollTimer) clearInterval(pollTimer);
152
+ if (watcher) watcher.close();
153
+ }
154
+ };
155
+ }
156
+ function sleep(ms) {
157
+ return new Promise((r) => setTimeout(r, ms));
158
+ }
159
+ export {
160
+ runTail
161
+ };