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.
@@ -0,0 +1,358 @@
1
+ // src/auth/dpop.ts
2
+ import { SignJWT, base64url } from "jose";
3
+ import crypto from "crypto";
4
+ async function createDPoPProof(privateKey, kid, method, url, nonce, accessToken) {
5
+ const payload = {
6
+ htm: method,
7
+ htu: url,
8
+ jti: crypto.randomUUID()
9
+ };
10
+ if (nonce) {
11
+ payload.nonce = nonce;
12
+ }
13
+ if (accessToken) {
14
+ payload.ath = computeAth(accessToken);
15
+ }
16
+ const header = {
17
+ typ: "dpop+jwt",
18
+ alg: "ES256",
19
+ jwk: { kid }
20
+ };
21
+ return new SignJWT(payload).setProtectedHeader(header).setIssuedAt().sign(privateKey);
22
+ }
23
+ function computeAth(accessToken) {
24
+ const hash = crypto.createHash("sha256").update(accessToken).digest();
25
+ return base64url.encode(hash);
26
+ }
27
+
28
+ // src/tandem/session-id.ts
29
+ import { ulid } from "ulidx";
30
+ var MCP_SESSION_ID = ulid();
31
+
32
+ // src/tandem/event-stream.ts
33
+ var STALE_FLOOR_MS = 9e4;
34
+ var STALE_INTERVAL_MULTIPLIER = 3;
35
+ var STALE_CHECK_INTERVAL_MS = 5e3;
36
+ var UNARMED_FALLBACK_MS = 12 * 6e4;
37
+ function createAdaptiveWatchdog(options = {}) {
38
+ const floorMs = options.floorMs ?? STALE_FLOOR_MS;
39
+ const multiplier = options.multiplier ?? STALE_INTERVAL_MULTIPLIER;
40
+ const unarmedFallbackMs = options.unarmedFallbackMs ?? UNARMED_FALLBACK_MS;
41
+ let prevHeartbeatAt = null;
42
+ let lastIntervalMs = null;
43
+ function thresholdMs() {
44
+ if (lastIntervalMs === null) return null;
45
+ return Math.max(lastIntervalMs * multiplier, floorMs);
46
+ }
47
+ return {
48
+ onHeartbeat(now) {
49
+ if (prevHeartbeatAt !== null) {
50
+ const interval = now - prevHeartbeatAt;
51
+ if (interval > 0) lastIntervalMs = interval;
52
+ }
53
+ prevHeartbeatAt = now;
54
+ },
55
+ onByte(_now) {
56
+ },
57
+ shouldAbort(lastByteAt, now) {
58
+ const t = thresholdMs();
59
+ if (t === null) {
60
+ return now - lastByteAt >= unarmedFallbackMs;
61
+ }
62
+ return now - lastByteAt >= t;
63
+ },
64
+ armedThresholdMs() {
65
+ return thresholdMs();
66
+ }
67
+ };
68
+ }
69
+ var BACKOFF_BASE_MS = 1e3;
70
+ var BACKOFF_CEILING_MS = 3e4;
71
+ var STABLE_CONNECTION_MS = 1e4;
72
+ function createBackoffController(options = {}) {
73
+ const baseMs = options.baseMs ?? BACKOFF_BASE_MS;
74
+ const ceilingMs = options.ceilingMs ?? BACKOFF_CEILING_MS;
75
+ const stableMs = options.stableMs ?? STABLE_CONNECTION_MS;
76
+ let backoffMs = baseMs;
77
+ let connectedAt = null;
78
+ return {
79
+ currentBackoffMs() {
80
+ return backoffMs;
81
+ },
82
+ markConnected(now) {
83
+ connectedAt = now;
84
+ },
85
+ markDisconnected(now) {
86
+ const lasted = connectedAt !== null ? now - connectedAt : 0;
87
+ connectedAt = null;
88
+ if (lasted >= stableMs) {
89
+ backoffMs = baseMs;
90
+ } else {
91
+ backoffMs = Math.min(backoffMs * 2, ceilingMs);
92
+ }
93
+ }
94
+ };
95
+ }
96
+ var LOG_HEARTBEAT_INTERVAL_MS = 6e4;
97
+ async function startEventStream(opts) {
98
+ let stopped = false;
99
+ const cursors = /* @__PURE__ */ new Map();
100
+ let currentController = new AbortController();
101
+ let connectGeneration = 0;
102
+ const state = {
103
+ connected: false,
104
+ connectedSince: null,
105
+ lastEventAt: null,
106
+ lastHeartbeatAt: null,
107
+ reconnectCount: 0,
108
+ // null until the adaptive watchdog learns a cadence (≥2 heartbeats).
109
+ staleAfterMs: null
110
+ };
111
+ void (async function loop() {
112
+ const backoff = createBackoffController();
113
+ while (!stopped) {
114
+ currentController = new AbortController();
115
+ const isReconnect = connectGeneration > 0;
116
+ try {
117
+ const opened = await connectAndConsume(
118
+ opts,
119
+ cursors,
120
+ currentController,
121
+ isReconnect,
122
+ state,
123
+ (tandemId, cursor) => {
124
+ const prev = cursors.get(tandemId);
125
+ if (prev === void 0 || cursor > prev) cursors.set(tandemId, cursor);
126
+ },
127
+ () => {
128
+ backoff.markConnected(Date.now());
129
+ connectGeneration += 1;
130
+ state.connected = true;
131
+ state.connectedSince = Date.now();
132
+ state.reconnectCount = connectGeneration - 1;
133
+ }
134
+ );
135
+ if (!opened && stopped) return;
136
+ } catch (err) {
137
+ if (stopped) return;
138
+ console.error("[event-stream] disconnect:", err.message);
139
+ state.connected = false;
140
+ await emitStatus(opts, `status=disconnected reason=${statusReason(err)}`);
141
+ }
142
+ if (stopped) return;
143
+ state.connected = false;
144
+ backoff.markDisconnected(Date.now());
145
+ const jitter = Math.random() * backoff.currentBackoffMs();
146
+ await sleep(jitter);
147
+ }
148
+ })();
149
+ const handle = (() => {
150
+ stopped = true;
151
+ state.connected = false;
152
+ currentController.abort();
153
+ });
154
+ handle.getState = () => ({
155
+ connected: state.connected,
156
+ connectedSince: state.connectedSince,
157
+ lastEventAt: state.lastEventAt,
158
+ lastHeartbeatAt: state.lastHeartbeatAt,
159
+ cursors: Object.fromEntries(cursors),
160
+ reconnectCount: state.reconnectCount,
161
+ staleAfterMs: state.staleAfterMs
162
+ });
163
+ return handle;
164
+ }
165
+ async function connectAndConsume(opts, cursors, controller, isReconnect, state, onCursor, onOpen) {
166
+ const mapSince = cursors.size > 0 ? serializeCursorMap(cursors) : null;
167
+ let res = await openStream(opts, controller, mapSince);
168
+ if (res.status === 400 && mapSince !== null) {
169
+ console.error("[event-stream] per-room ?since rejected (400) \u2014 falling back to bare cursor");
170
+ res = await openStream(opts, controller, String(Math.min(...cursors.values())));
171
+ }
172
+ if (!res.ok) throw new Error(`SSE connect failed: ${res.status}`);
173
+ if (!res.body) throw new Error("SSE response has no body");
174
+ onOpen();
175
+ await emitStatus(opts, isReconnect ? "status=reconnected" : "status=connected");
176
+ if (opts.onConnected) {
177
+ void Promise.resolve().then(() => opts.onConnected()).catch((err) => {
178
+ console.error("[event-stream] onConnected (resubscribe) failed:", err.message);
179
+ });
180
+ }
181
+ await consumeSse(res.body, opts, controller, state, onCursor);
182
+ return true;
183
+ }
184
+ async function openStream(opts, controller, sinceValue) {
185
+ const params = new URLSearchParams();
186
+ if (sinceValue !== null) params.set("since", sinceValue);
187
+ const url = `${opts.brokerUrl}/api/v2/tandems/stream${params.toString() ? "?" + params.toString() : ""}`;
188
+ const proof = await createDPoPProof(
189
+ opts.gateway.getPrivateKey(),
190
+ opts.gateway.getKid(),
191
+ "GET",
192
+ url,
193
+ void 0,
194
+ opts.token
195
+ );
196
+ return fetch(url, {
197
+ method: "GET",
198
+ headers: {
199
+ Authorization: `DPoP ${opts.token}`,
200
+ DPoP: proof,
201
+ "Mcp-Session-Id": MCP_SESSION_ID,
202
+ Accept: "text/event-stream"
203
+ },
204
+ signal: controller.signal
205
+ });
206
+ }
207
+ async function consumeSse(body, opts, controller, state, onCursor) {
208
+ const reader = body.getReader();
209
+ const decoder = new TextDecoder();
210
+ let buffer = "";
211
+ let lastByteAt = Date.now();
212
+ const watchdog = createAdaptiveWatchdog();
213
+ let lastLogHeartbeatAt = 0;
214
+ const watchdogTimer = setInterval(() => {
215
+ if (watchdog.shouldAbort(lastByteAt, Date.now())) {
216
+ const armed = watchdog.armedThresholdMs();
217
+ const reason = armed !== null ? `no bytes for \u2265${armed}ms, cadence learned` : `no bytes for \u2265${UNARMED_FALLBACK_MS}ms, UNARMED fallback (no cadence learned \u2014 presumed wedged)`;
218
+ console.error(`[event-stream] stale connection (${reason}) \u2014 aborting to reconnect`);
219
+ controller.abort();
220
+ }
221
+ }, STALE_CHECK_INTERVAL_MS);
222
+ try {
223
+ while (true) {
224
+ const { value, done } = await reader.read();
225
+ if (done) return;
226
+ lastByteAt = Date.now();
227
+ watchdog.onByte(lastByteAt);
228
+ state.lastEventAt = lastByteAt;
229
+ buffer += decoder.decode(value, { stream: true });
230
+ const events = drainSseEvents(buffer);
231
+ buffer = events.remaining;
232
+ for (const evt of events.events) {
233
+ if (evt.event === "stream_revoked") {
234
+ throw new Error("stream_revoked \u2014 reconnect needed");
235
+ }
236
+ if (evt.event === "heartbeat") {
237
+ const now = Date.now();
238
+ state.lastHeartbeatAt = now;
239
+ watchdog.onHeartbeat(now);
240
+ state.staleAfterMs = watchdog.armedThresholdMs();
241
+ if (now - lastLogHeartbeatAt >= LOG_HEARTBEAT_INTERVAL_MS) {
242
+ lastLogHeartbeatAt = now;
243
+ void opts.eventLog?.appendStatus("status=heartbeat").catch(() => {
244
+ });
245
+ }
246
+ continue;
247
+ }
248
+ if (evt.event === "message" || evt.event === "state_change") {
249
+ try {
250
+ const raw = JSON.parse(evt.data);
251
+ const parsed = normalizeBackendEvent(raw, evt.event);
252
+ onCursor(parsed.tandem_id, parsed.cursor);
253
+ opts.queue?.push(parsed);
254
+ if (opts.eventLog) {
255
+ try {
256
+ await opts.eventLog.append(parsed);
257
+ opts.queue?.markMonitorDelivered(parsed.id);
258
+ } catch (err) {
259
+ console.error("[event-stream] event-log append failed:", err);
260
+ }
261
+ }
262
+ try {
263
+ const channel = opts.adapter.formatTandemEvent(parsed);
264
+ await opts.server.notification({
265
+ method: "notifications/claude/channel",
266
+ params: channel
267
+ });
268
+ opts.queue?.markChannelDelivered(parsed.id);
269
+ } catch (err) {
270
+ console.error("[event-stream] channel notification failed:", err);
271
+ }
272
+ } catch (err) {
273
+ console.error("[event-stream] failed to handle event:", err);
274
+ }
275
+ }
276
+ }
277
+ }
278
+ } finally {
279
+ clearInterval(watchdogTimer);
280
+ try {
281
+ reader.releaseLock();
282
+ } catch {
283
+ }
284
+ }
285
+ }
286
+ async function emitStatus(opts, fields) {
287
+ try {
288
+ await opts.eventLog?.appendStatus(fields);
289
+ } catch {
290
+ }
291
+ }
292
+ function statusReason(err) {
293
+ const msg = err?.message ?? String(err);
294
+ return msg.replace(/\s+/g, "_").slice(0, 60);
295
+ }
296
+ function serializeCursorMap(cursors) {
297
+ return [...cursors.entries()].map(([tandemId, cursor]) => `${tandemId}:${cursor}`).join(",");
298
+ }
299
+ function drainSseEvents(input) {
300
+ const events = [];
301
+ const parts = input.split("\n\n");
302
+ const remaining = parts.pop() ?? "";
303
+ for (const block of parts) {
304
+ let id;
305
+ let event = "message";
306
+ const dataLines = [];
307
+ for (const line of block.split("\n")) {
308
+ if (line.startsWith("id: ")) id = line.slice(4);
309
+ else if (line.startsWith("event: ")) event = line.slice(7);
310
+ else if (line.startsWith("data: ")) dataLines.push(line.slice(6));
311
+ }
312
+ if (dataLines.length > 0) events.push({ id, event, data: dataLines.join("\n") });
313
+ }
314
+ return { events, remaining };
315
+ }
316
+ function sleep(ms) {
317
+ return new Promise((r) => setTimeout(r, ms));
318
+ }
319
+ function normalizeBackendEvent(raw, sseEventType) {
320
+ const obj = raw ?? {};
321
+ const maybeFrom = obj["from"];
322
+ if (maybeFrom && typeof maybeFrom["principal"] === "string") {
323
+ return raw;
324
+ }
325
+ const sender = obj["sender"] ?? {};
326
+ const principal = sender["principal_id"] ?? "";
327
+ const agentId = sender["agent_id"];
328
+ const displayname = principal ? `principal:${principal.slice(0, 8)}` : "unknown";
329
+ const type = sseEventType === "state_change" ? "state_change" : "message";
330
+ const kind = obj["kind"] ?? "message";
331
+ return {
332
+ type,
333
+ id: obj["message_id"] ?? obj["id"] ?? "",
334
+ tandem_id: obj["tandem_id"] ?? "",
335
+ cursor: obj["cursor"] ?? 0,
336
+ time: obj["time"] ?? (/* @__PURE__ */ new Date()).toISOString(),
337
+ from: {
338
+ member_id: "",
339
+ principal,
340
+ ...agentId ? { agent_id: agentId } : {},
341
+ displayname
342
+ },
343
+ kind,
344
+ content: {
345
+ body: obj["body"] ?? "",
346
+ ...typeof obj["format"] === "string" ? { format: obj["format"] } : {}
347
+ },
348
+ ...Array.isArray(obj["mentions"]) ? { mentions: obj["mentions"] } : {},
349
+ ...obj["reply_to"] !== void 0 ? { reply_to: obj["reply_to"] } : {}
350
+ };
351
+ }
352
+
353
+ export {
354
+ createDPoPProof,
355
+ MCP_SESSION_ID,
356
+ createAdaptiveWatchdog,
357
+ startEventStream
358
+ };
@@ -0,0 +1,247 @@
1
+ import {
2
+ sessionDiscoveryDir
3
+ } from "./chunk-W6YRLSD4.js";
4
+
5
+ // src/tandem/event-log.ts
6
+ import fs from "fs";
7
+ import os from "os";
8
+ import path from "path";
9
+ var DEFAULT_DIR = os.tmpdir();
10
+ var MAX_BODY_CHARS = 200;
11
+ var DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
12
+ var DEFAULT_STATUS_MAX_BYTES = 1 * 1024 * 1024;
13
+ var DEFAULT_MIN_AGE_MS = 6e4;
14
+ var STATUS_LINE_PREFIX = "kojee-status";
15
+ function statusLogPath(eventLogPath) {
16
+ const dir = path.dirname(eventLogPath);
17
+ const base = path.basename(eventLogPath);
18
+ const m = base.match(/^kojee-events-(.+)\.log$/);
19
+ return path.join(dir, m ? `kojee-status-${m[1]}.log` : `${base}.status`);
20
+ }
21
+ function startEventLog(opts) {
22
+ const dir = opts.dir ?? DEFAULT_DIR;
23
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
24
+ const statusMaxBytes = opts.statusMaxBytes ?? DEFAULT_STATUS_MAX_BYTES;
25
+ const filePath = path.join(dir, `kojee-events-${opts.key}.log`);
26
+ const statusPath = statusLogPath(filePath);
27
+ fs.mkdirSync(dir, { recursive: true });
28
+ fs.writeFileSync(filePath, "", { mode: 384 });
29
+ try {
30
+ fs.chmodSync(filePath, 384);
31
+ } catch {
32
+ }
33
+ fs.writeFileSync(statusPath, "", { mode: 384 });
34
+ try {
35
+ fs.chmodSync(statusPath, 384);
36
+ } catch {
37
+ }
38
+ let writtenIds = /* @__PURE__ */ new Set();
39
+ let bytesWritten = 0;
40
+ let linesWritten = 0;
41
+ let statusBytesWritten = 0;
42
+ let writingMsg = Promise.resolve();
43
+ function enqueueMsg(fn) {
44
+ writingMsg = writingMsg.then(fn, fn);
45
+ return writingMsg;
46
+ }
47
+ let writingStatus = Promise.resolve();
48
+ function enqueueStatus(fn) {
49
+ writingStatus = writingStatus.then(fn, fn);
50
+ return writingStatus;
51
+ }
52
+ async function writeStatusRaw(fields) {
53
+ const note = formatStatusLine(fields);
54
+ if (statusBytesWritten >= statusMaxBytes) {
55
+ try {
56
+ await fs.promises.truncate(statusPath, 0);
57
+ statusBytesWritten = 0;
58
+ } catch (err) {
59
+ console.error("[event-log] status rotate-truncate failed:", err.message);
60
+ }
61
+ }
62
+ await fs.promises.appendFile(statusPath, note + "\n", { encoding: "utf8" });
63
+ statusBytesWritten += Buffer.byteLength(note + "\n");
64
+ }
65
+ async function writeMsgRaw(line) {
66
+ if (bytesWritten >= maxBytes) {
67
+ const dropped = linesWritten;
68
+ try {
69
+ await fs.promises.truncate(filePath, 0);
70
+ bytesWritten = 0;
71
+ linesWritten = 0;
72
+ writtenIds = /* @__PURE__ */ new Set();
73
+ void enqueueStatus(() => writeStatusRaw(`status=rotated dropped=${dropped}`)).catch(() => {
74
+ });
75
+ } catch (err) {
76
+ console.error("[event-log] rotate-truncate failed:", err.message);
77
+ }
78
+ }
79
+ await fs.promises.appendFile(filePath, line + "\n", { encoding: "utf8" });
80
+ bytesWritten += Buffer.byteLength(line + "\n");
81
+ linesWritten += 1;
82
+ }
83
+ return {
84
+ path: filePath,
85
+ statusPath,
86
+ async append(event) {
87
+ if (event.id && writtenIds.has(event.id)) return;
88
+ if (event.id) writtenIds.add(event.id);
89
+ const line = formatLine(event);
90
+ await enqueueMsg(async () => {
91
+ try {
92
+ await writeMsgRaw(line);
93
+ } catch (err) {
94
+ console.error("[event-log] append failed:", err.message);
95
+ if (event.id) writtenIds.delete(event.id);
96
+ }
97
+ });
98
+ },
99
+ async appendStatus(fields) {
100
+ await enqueueStatus(async () => {
101
+ try {
102
+ await writeStatusRaw(fields);
103
+ } catch (err) {
104
+ console.error("[event-log] appendStatus failed:", err.message);
105
+ }
106
+ });
107
+ },
108
+ cleanup() {
109
+ try {
110
+ fs.unlinkSync(filePath);
111
+ } catch {
112
+ }
113
+ try {
114
+ fs.unlinkSync(statusPath);
115
+ } catch {
116
+ }
117
+ }
118
+ };
119
+ }
120
+ function formatStatusLine(fields) {
121
+ return `[${(/* @__PURE__ */ new Date()).toISOString()}] ${STATUS_LINE_PREFIX} ${fields}`;
122
+ }
123
+ function formatLine(event) {
124
+ const body = (event.content?.body ?? "").replace(/[\r\n]+/g, " ").slice(0, MAX_BODY_CHARS + 1);
125
+ const truncated = body.length > MAX_BODY_CHARS;
126
+ const safeBody = truncated ? body.slice(0, MAX_BODY_CHARS) + "\u2026" : body;
127
+ return `[${event.time}] tandem=${event.tandem_id} from=${event.from.displayname} (${event.from.principal}) kind=${event.kind} cursor=${event.cursor} msg=${event.id}: ${safeBody}`;
128
+ }
129
+ function monitorHeartbeatPath(eventLogPath) {
130
+ const dir = path.dirname(eventLogPath);
131
+ const base = path.basename(eventLogPath);
132
+ const m = base.match(/^kojee-events-(.+)\.log$/);
133
+ return path.join(dir, m ? `kojee-monitor-${m[1]}.alive` : `${base}.alive`);
134
+ }
135
+ function nudgeSentinelPath(eventLogPath) {
136
+ const dir = path.dirname(eventLogPath);
137
+ const base = path.basename(eventLogPath);
138
+ const m = base.match(/^kojee-events-(.+)\.log$/);
139
+ return path.join(dir, m ? `kojee-nudge-${m[1]}.touch` : `${base}.nudge`);
140
+ }
141
+ function isProcessAlive(pid) {
142
+ try {
143
+ process.kill(pid, 0);
144
+ return true;
145
+ } catch (err) {
146
+ if (err.code === "EPERM") return true;
147
+ return false;
148
+ }
149
+ }
150
+ function listActiveSessionIds() {
151
+ const dir = sessionDiscoveryDir();
152
+ const active = /* @__PURE__ */ new Set();
153
+ const livePaths = /* @__PURE__ */ new Set();
154
+ let entries;
155
+ try {
156
+ entries = fs.readdirSync(dir);
157
+ } catch {
158
+ return { active, livePaths };
159
+ }
160
+ for (const name of entries) {
161
+ if (!name.endsWith(".json")) continue;
162
+ const rawId = name.slice(0, -".json".length);
163
+ const sessionId = rawId.startsWith("cc-") ? rawId.slice("cc-".length) : rawId;
164
+ const filePath = path.join(dir, name);
165
+ try {
166
+ const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
167
+ if (typeof data.pid === "number" && isProcessAlive(data.pid)) {
168
+ active.add(sessionId);
169
+ if (data.eventLogPath) livePaths.add(path.resolve(data.eventLogPath));
170
+ } else {
171
+ try {
172
+ fs.unlinkSync(filePath);
173
+ } catch {
174
+ }
175
+ if (data.eventLogPath) {
176
+ try {
177
+ fs.unlinkSync(data.eventLogPath);
178
+ } catch {
179
+ }
180
+ try {
181
+ fs.unlinkSync(statusLogPath(data.eventLogPath));
182
+ } catch {
183
+ }
184
+ }
185
+ }
186
+ } catch {
187
+ }
188
+ }
189
+ return { active, livePaths };
190
+ }
191
+ function sweepStaleEventLogs(dir = DEFAULT_DIR, minAgeMs = DEFAULT_MIN_AGE_MS) {
192
+ try {
193
+ fs.unlinkSync(path.join(dir, "kojee-events-no-session.log"));
194
+ } catch {
195
+ }
196
+ const { active, livePaths } = listActiveSessionIds();
197
+ let entries;
198
+ try {
199
+ entries = fs.readdirSync(dir);
200
+ } catch {
201
+ return;
202
+ }
203
+ const now = Date.now();
204
+ for (const name of entries) {
205
+ if (!name.startsWith("kojee-events-") || !name.endsWith(".log")) continue;
206
+ const sessionId = name.slice("kojee-events-".length, -".log".length);
207
+ if (active.has(sessionId)) continue;
208
+ const filePath = path.join(dir, name);
209
+ if (livePaths.has(path.resolve(filePath))) continue;
210
+ try {
211
+ const stat = fs.statSync(filePath);
212
+ const ageMs = Math.max(0, now - stat.mtimeMs);
213
+ if (ageMs < minAgeMs) continue;
214
+ fs.unlinkSync(filePath);
215
+ try {
216
+ fs.unlinkSync(statusLogPath(filePath));
217
+ } catch {
218
+ }
219
+ } catch {
220
+ }
221
+ }
222
+ for (const name of entries) {
223
+ if (!name.startsWith("kojee-status-") || !name.endsWith(".log")) continue;
224
+ const statusFile = path.join(dir, name);
225
+ const key = name.slice("kojee-status-".length, -".log".length);
226
+ const messagesFile = path.join(dir, `kojee-events-${key}.log`);
227
+ if (active.has(key)) continue;
228
+ if (livePaths.has(path.resolve(messagesFile))) continue;
229
+ if (fs.existsSync(messagesFile)) continue;
230
+ try {
231
+ const stat = fs.statSync(statusFile);
232
+ const ageMs = Math.max(0, now - stat.mtimeMs);
233
+ if (ageMs < minAgeMs) continue;
234
+ fs.unlinkSync(statusFile);
235
+ } catch {
236
+ }
237
+ }
238
+ }
239
+
240
+ export {
241
+ STATUS_LINE_PREFIX,
242
+ statusLogPath,
243
+ startEventLog,
244
+ monitorHeartbeatPath,
245
+ nudgeSentinelPath,
246
+ sweepStaleEventLogs
247
+ };
@@ -1,8 +1,9 @@
1
1
  // src/tandem/session-discovery.ts
2
2
  import fs from "fs";
3
+ import os from "os";
3
4
  import path from "path";
4
5
  function sessionDiscoveryDir() {
5
- return path.join(process.env["HOME"] ?? "~", ".kojee", "sessions");
6
+ return path.join(os.homedir(), ".kojee", "sessions");
6
7
  }
7
8
  function sessionDiscoveryPath(sessionId) {
8
9
  return path.join(sessionDiscoveryDir(), `${sessionId}.json`);