@danielblomma/cortex-mcp 1.7.2 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +4 -24
  2. package/bin/cortex.mjs +679 -32
  3. package/bin/style.mjs +349 -0
  4. package/package.json +4 -3
  5. package/scaffold/mcp/src/cli/enterprise-setup.ts +124 -0
  6. package/scaffold/mcp/src/cli/govern.ts +987 -0
  7. package/scaffold/mcp/src/cli/run.ts +306 -0
  8. package/scaffold/mcp/src/cli/telemetry-test.ts +158 -0
  9. package/scaffold/mcp/src/cli/ungoverned-detector.ts +168 -0
  10. package/scaffold/mcp/src/core/audit/query.ts +81 -0
  11. package/scaffold/mcp/src/core/audit/writer.ts +68 -0
  12. package/scaffold/mcp/src/core/config.ts +329 -0
  13. package/scaffold/mcp/src/core/index.ts +34 -0
  14. package/scaffold/mcp/src/core/license.ts +202 -0
  15. package/scaffold/mcp/src/core/policy/enforce.ts +98 -0
  16. package/scaffold/mcp/src/core/policy/injection.ts +229 -0
  17. package/scaffold/mcp/src/core/policy/store.ts +197 -0
  18. package/scaffold/mcp/src/core/rbac/check.ts +40 -0
  19. package/scaffold/mcp/src/core/telemetry/collector.ts +408 -0
  20. package/scaffold/mcp/src/core/validators/builtins.ts +711 -0
  21. package/scaffold/mcp/src/core/validators/config.ts +47 -0
  22. package/scaffold/mcp/src/core/validators/engine.ts +199 -0
  23. package/scaffold/mcp/src/core/validators/evaluators/code_comments.ts +294 -0
  24. package/scaffold/mcp/src/core/validators/evaluators/regex.ts +144 -0
  25. package/scaffold/mcp/src/daemon/client.ts +155 -0
  26. package/scaffold/mcp/src/daemon/egress-proxy.ts +331 -0
  27. package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +147 -0
  28. package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +223 -0
  29. package/scaffold/mcp/src/daemon/host-events-pusher.ts +285 -0
  30. package/scaffold/mcp/src/daemon/main.ts +435 -0
  31. package/scaffold/mcp/src/daemon/paths.ts +41 -0
  32. package/scaffold/mcp/src/daemon/protocol.ts +101 -0
  33. package/scaffold/mcp/src/daemon/server.ts +227 -0
  34. package/scaffold/mcp/src/daemon/sync-checker.ts +213 -0
  35. package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +149 -0
  36. package/scaffold/mcp/src/enterprise/audit/push.ts +84 -0
  37. package/scaffold/mcp/src/enterprise/index.ts +386 -0
  38. package/scaffold/mcp/src/enterprise/model/deploy.ts +33 -0
  39. package/scaffold/mcp/src/enterprise/policy/sync.ts +146 -0
  40. package/scaffold/mcp/src/enterprise/privacy/boundary.ts +214 -0
  41. package/scaffold/mcp/src/enterprise/reviews/push.ts +79 -0
  42. package/scaffold/mcp/src/enterprise/telemetry/sync.ts +73 -0
  43. package/scaffold/mcp/src/enterprise/tools/enterprise.ts +1031 -0
  44. package/scaffold/mcp/src/enterprise/tools/walk.ts +79 -0
  45. package/scaffold/mcp/src/enterprise/violations/push.ts +102 -0
  46. package/scaffold/mcp/src/enterprise/workflow/push.ts +60 -0
  47. package/scaffold/mcp/src/enterprise/workflow/state.ts +535 -0
  48. package/scaffold/mcp/src/hooks/pre-compact.ts +54 -0
  49. package/scaffold/mcp/src/hooks/pre-tool-use.ts +96 -0
  50. package/scaffold/mcp/src/hooks/session-end.ts +73 -0
  51. package/scaffold/mcp/src/hooks/session-start.ts +78 -0
  52. package/scaffold/mcp/src/hooks/shared.ts +134 -0
  53. package/scaffold/mcp/src/hooks/stop.ts +60 -0
  54. package/scaffold/mcp/src/hooks/user-prompt-submit.ts +64 -0
  55. package/scaffold/mcp/src/loadGraph.ts +2 -0
  56. package/scaffold/mcp/src/plugin.ts +150 -0
  57. package/scaffold/mcp/src/server.ts +218 -7
  58. package/scaffold/mcp/tests/copilot-shim.test.mjs +146 -0
  59. package/scaffold/mcp/tests/daemon-client.test.mjs +32 -0
  60. package/scaffold/mcp/tests/egress-proxy.test.mjs +239 -0
  61. package/scaffold/mcp/tests/enterprise-config.test.mjs +154 -0
  62. package/scaffold/mcp/tests/govern-install.test.mjs +320 -0
  63. package/scaffold/mcp/tests/govern-repair.test.mjs +157 -0
  64. package/scaffold/mcp/tests/govern-status.test.mjs +538 -0
  65. package/scaffold/mcp/tests/govern.test.mjs +74 -0
  66. package/scaffold/mcp/tests/heartbeat-pusher.test.mjs +154 -0
  67. package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +237 -0
  68. package/scaffold/mcp/tests/host-events-pusher.test.mjs +347 -0
  69. package/scaffold/mcp/tests/policy-check.test.mjs +220 -0
  70. package/scaffold/mcp/tests/repo-name.test.mjs +134 -0
  71. package/scaffold/mcp/tests/run.test.mjs +109 -0
  72. package/scaffold/mcp/tests/sync-checker.test.mjs +188 -0
  73. package/scaffold/mcp/tests/telemetry-collector.test.mjs +30 -0
  74. package/scaffold/mcp/tests/ungoverned-detector.test.mjs +191 -0
  75. package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +198 -0
  76. package/scaffold/scripts/bootstrap.sh +0 -11
  77. package/scaffold/scripts/doctor.sh +24 -4
  78. package/types.js +5 -0
  79. package/docs/MCP_MARKETPLACE.md +0 -160
@@ -0,0 +1,223 @@
1
+ import { writeFileSync, existsSync, unlinkSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { hostname } from "node:os";
4
+ import type { HeartbeatPayload, HeartbeatResult } from "./protocol.js";
5
+ import { writeHostAuditEvent } from "./ungoverned-scanner.js";
6
+
7
+ /**
8
+ * Hook heartbeat tracker for Phase 6 tamper-detection.
9
+ *
10
+ * Each hook invocation pings the daemon. The daemon tracks per-session
11
+ * "last seen any hook" timestamps. A session is considered active from
12
+ * the first SessionStart heartbeat until SessionEnd (or auto-cleanup
13
+ * after a long stale interval).
14
+ *
15
+ * Tamper detection (periodic): if an active session has had at least
16
+ * one non-SessionStart heartbeat (so we know hooks were genuinely
17
+ * firing) and then nothing within `missingThresholdSeconds`, we flag
18
+ * it. Pure idle sessions where the user just left Claude open without
19
+ * doing anything do not match the "had-activity-then-silence" pattern.
20
+ */
21
+
22
+ export type SessionState = {
23
+ cli: HeartbeatPayload["cli"];
24
+ cwd: string;
25
+ started_at: string;
26
+ last_heartbeat: string;
27
+ hook_count: number;
28
+ ended: boolean;
29
+ ended_at?: string;
30
+ };
31
+
32
+ export type TamperLockEntry = {
33
+ version: 1;
34
+ detected_at: string;
35
+ cli: HeartbeatPayload["cli"];
36
+ session_id: string;
37
+ hook_name: string;
38
+ last_seen: string;
39
+ missing_seconds: number;
40
+ host_id: string;
41
+ cwd: string;
42
+ };
43
+
44
+ export const TAMPER_LOCK_FILENAME = ".cortex-tamper.lock";
45
+
46
+ export type TamperCheckOptions = {
47
+ cwds: string[];
48
+ missingThresholdSeconds: number;
49
+ now?: Date;
50
+ onTamperDetected?: (entry: TamperLockEntry) => void;
51
+ };
52
+
53
+ export class HeartbeatTracker {
54
+ private sessions = new Map<string, SessionState>();
55
+ private hostId: string;
56
+ private cleanupAfterMs: number;
57
+
58
+ constructor(
59
+ options: { hostId?: string; cleanupAfterMs?: number } = {},
60
+ ) {
61
+ this.hostId = options.hostId ?? hostname();
62
+ // Sessions with no heartbeat for this long are auto-removed (covers
63
+ // crashes that never sent SessionEnd). Default 12h.
64
+ this.cleanupAfterMs = options.cleanupAfterMs ?? 12 * 60 * 60 * 1000;
65
+ }
66
+
67
+ recordHeartbeat(payload: HeartbeatPayload): HeartbeatResult {
68
+ const existing = this.sessions.get(payload.session_id);
69
+ const now = payload.ts;
70
+
71
+ if (payload.hook === "SessionStart") {
72
+ this.sessions.set(payload.session_id, {
73
+ cli: payload.cli,
74
+ cwd: payload.cwd,
75
+ started_at: now,
76
+ last_heartbeat: now,
77
+ hook_count: 1,
78
+ ended: false,
79
+ });
80
+ } else if (payload.hook === "SessionEnd") {
81
+ if (existing) {
82
+ existing.last_heartbeat = now;
83
+ existing.ended = true;
84
+ existing.ended_at = now;
85
+ existing.hook_count += 1;
86
+ } else {
87
+ // SessionEnd without prior SessionStart — register a closed session
88
+ // so the tamper-checker doesn't flag it later.
89
+ this.sessions.set(payload.session_id, {
90
+ cli: payload.cli,
91
+ cwd: payload.cwd,
92
+ started_at: now,
93
+ last_heartbeat: now,
94
+ hook_count: 1,
95
+ ended: true,
96
+ ended_at: now,
97
+ });
98
+ }
99
+ } else {
100
+ if (existing) {
101
+ existing.last_heartbeat = now;
102
+ existing.hook_count += 1;
103
+ } else {
104
+ // Heartbeat from a session we never saw start (daemon was restarted
105
+ // mid-session). Register it as active.
106
+ this.sessions.set(payload.session_id, {
107
+ cli: payload.cli,
108
+ cwd: payload.cwd,
109
+ started_at: now,
110
+ last_heartbeat: now,
111
+ hook_count: 1,
112
+ ended: false,
113
+ });
114
+ }
115
+ }
116
+
117
+ const tamperLockActive = existsSync(
118
+ join(payload.cwd, ".context", TAMPER_LOCK_FILENAME),
119
+ );
120
+ return { recorded: true, tamper_lock_active: tamperLockActive };
121
+ }
122
+
123
+ getActiveSessions(): Array<[string, SessionState]> {
124
+ return Array.from(this.sessions.entries()).filter(
125
+ ([, state]) => !state.ended,
126
+ );
127
+ }
128
+
129
+ detectTamper(options: TamperCheckOptions): TamperLockEntry[] {
130
+ const now = options.now ?? new Date();
131
+ const thresholdMs = options.missingThresholdSeconds * 1000;
132
+ const flagged: TamperLockEntry[] = [];
133
+
134
+ for (const [sessionId, state] of this.sessions) {
135
+ // Cleanup sessions that have been silent forever — they crashed.
136
+ const lastMs = new Date(state.last_heartbeat).getTime();
137
+ if (Number.isFinite(lastMs) && now.getTime() - lastMs > this.cleanupAfterMs) {
138
+ this.sessions.delete(sessionId);
139
+ continue;
140
+ }
141
+
142
+ if (state.ended) continue;
143
+ // Need at least 2 heartbeats for the "had-activity-then-silence"
144
+ // signal — a single SessionStart followed by silence may just be a
145
+ // user opening Claude and walking away.
146
+ if (state.hook_count < 2) continue;
147
+
148
+ const elapsedMs = now.getTime() - lastMs;
149
+ if (elapsedMs <= thresholdMs) continue;
150
+
151
+ const entry: TamperLockEntry = {
152
+ version: 1,
153
+ detected_at: now.toISOString(),
154
+ cli: state.cli,
155
+ session_id: sessionId,
156
+ hook_name: "any",
157
+ last_seen: state.last_heartbeat,
158
+ missing_seconds: Math.round(elapsedMs / 1000),
159
+ host_id: this.hostId,
160
+ cwd: state.cwd,
161
+ };
162
+ flagged.push(entry);
163
+ // Mark ended so we don't re-flag the same session every tick.
164
+ state.ended = true;
165
+ state.ended_at = now.toISOString();
166
+ options.onTamperDetected?.(entry);
167
+ }
168
+
169
+ return flagged;
170
+ }
171
+
172
+ // For tests:
173
+ _forceState(sessionId: string, state: SessionState): void {
174
+ this.sessions.set(sessionId, state);
175
+ }
176
+ _size(): number {
177
+ return this.sessions.size;
178
+ }
179
+ }
180
+
181
+ export function writeTamperLock(cwd: string, entry: TamperLockEntry): string {
182
+ const dir = join(cwd, ".context");
183
+ const path = join(dir, TAMPER_LOCK_FILENAME);
184
+ writeFileSync(path, JSON.stringify(entry, null, 2) + "\n", "utf8");
185
+ return path;
186
+ }
187
+
188
+ export function readTamperLock(cwd: string): TamperLockEntry | null {
189
+ const path = join(cwd, ".context", TAMPER_LOCK_FILENAME);
190
+ if (!existsSync(path)) return null;
191
+ try {
192
+ return JSON.parse(readFileSync(path, "utf8")) as TamperLockEntry;
193
+ } catch {
194
+ return null;
195
+ }
196
+ }
197
+
198
+ export function removeTamperLock(cwd: string): boolean {
199
+ const path = join(cwd, ".context", TAMPER_LOCK_FILENAME);
200
+ if (!existsSync(path)) return false;
201
+ try {
202
+ unlinkSync(path);
203
+ return true;
204
+ } catch {
205
+ return false;
206
+ }
207
+ }
208
+
209
+ export async function emitTamperAudit(
210
+ cwd: string,
211
+ entry: TamperLockEntry,
212
+ ): Promise<void> {
213
+ await writeHostAuditEvent(cwd, {
214
+ event_type: "hook_tamper_detected",
215
+ timestamp: entry.detected_at,
216
+ host_id: entry.host_id,
217
+ cli: entry.cli,
218
+ session_id: entry.session_id,
219
+ hook_name: entry.hook_name,
220
+ last_seen: entry.last_seen,
221
+ missing_seconds: entry.missing_seconds,
222
+ });
223
+ }
@@ -0,0 +1,285 @@
1
+ import {
2
+ existsSync,
3
+ readFileSync,
4
+ readdirSync,
5
+ writeFileSync,
6
+ mkdirSync,
7
+ statSync,
8
+ } from "node:fs";
9
+ import { join, dirname } from "node:path";
10
+ import { loadEnterpriseConfig } from "../core/config.js";
11
+
12
+ /**
13
+ * Phase 7 sync flow — host-events pusher.
14
+ *
15
+ * The daemon writes ungoverned-session and hook-tamper events to
16
+ * .context/audit/host-events-YYYY-MM-DD.jsonl. This pusher batches
17
+ * unpushed events and POSTs them to the cortex-web govern endpoints.
18
+ *
19
+ * State (.context/.cortex-host-events-cursor.json) tracks the last
20
+ * pushed timestamp per event_type so we don't double-push or skip.
21
+ */
22
+
23
+ const CURSOR_FILENAME = ".cortex-host-events-cursor.json";
24
+
25
+ type Cursor = {
26
+ ungoverned_last_ts?: string;
27
+ tamper_last_ts?: string;
28
+ };
29
+
30
+ /**
31
+ * Cursor format is `${ISO_TIMESTAMP}#${stable_id}` where stable_id is
32
+ * the pid for ungoverned events and the session_id for tamper events.
33
+ * The composite suffix breaks ties when two writers emit at the exact
34
+ * same millisecond (clock resolution / two daemon producers).
35
+ *
36
+ * For backward compatibility, a persisted value with no `#` is treated
37
+ * as `${ts}#~`: `~` sorts after every printable ASCII character we
38
+ * actually emit, so an old cursor still skips all events at-or-before
39
+ * its timestamp on the first read after upgrade.
40
+ */
41
+ function readCursor(cwd: string): Cursor {
42
+ const path = join(cwd, ".context", CURSOR_FILENAME);
43
+ if (!existsSync(path)) return {};
44
+ try {
45
+ return JSON.parse(readFileSync(path, "utf8")) as Cursor;
46
+ } catch {
47
+ return {};
48
+ }
49
+ }
50
+
51
+ function normalizeCursor(value: string | undefined): string | undefined {
52
+ if (!value) return undefined;
53
+ return value.includes("#") ? value : `${value}#~`;
54
+ }
55
+
56
+ function stableIdFor(evt: HostEvent): string {
57
+ if (evt.event_type === "ungoverned_ai_session_detected") {
58
+ const pid = evt.pid;
59
+ if (typeof pid === "number") return String(pid);
60
+ if (typeof pid === "string" && pid.length > 0) return pid;
61
+ return "0";
62
+ }
63
+ if (evt.event_type === "hook_tamper_detected") {
64
+ const sid = evt.session_id;
65
+ if (typeof sid === "string" && sid.length > 0) return sid;
66
+ return "";
67
+ }
68
+ return "";
69
+ }
70
+
71
+ function compositeKey(evt: HostEvent): string | null {
72
+ const ts = eventTimestamp(evt);
73
+ if (!ts) return null;
74
+ return `${ts}#${stableIdFor(evt)}`;
75
+ }
76
+
77
+ function sortByTs<T extends HostEvent>(arr: T[]): T[] {
78
+ return arr.sort((a, b) => {
79
+ const ka = compositeKey(a) ?? "";
80
+ const kb = compositeKey(b) ?? "";
81
+ if (ka < kb) return -1;
82
+ if (ka > kb) return 1;
83
+ return 0;
84
+ });
85
+ }
86
+
87
+ function writeCursor(cwd: string, cursor: Cursor): void {
88
+ const path = join(cwd, ".context", CURSOR_FILENAME);
89
+ mkdirSync(dirname(path), { recursive: true });
90
+ writeFileSync(path, JSON.stringify(cursor, null, 2) + "\n", "utf8");
91
+ }
92
+
93
+ type HostEvent = Record<string, unknown> & { event_type?: string; timestamp?: string };
94
+
95
+ function listHostEventFiles(cwd: string): string[] {
96
+ const dir = join(cwd, ".context", "audit");
97
+ if (!existsSync(dir)) return [];
98
+ return readdirSync(dir)
99
+ .filter((name) => name.startsWith("host-events-") && name.endsWith(".jsonl"))
100
+ .sort()
101
+ .map((name) => join(dir, name));
102
+ }
103
+
104
+ function readEventsFrom(file: string): HostEvent[] {
105
+ if (!existsSync(file)) return [];
106
+ const stat = statSync(file);
107
+ if (!stat.isFile()) return [];
108
+ const raw = readFileSync(file, "utf8");
109
+ const out: HostEvent[] = [];
110
+ for (const line of raw.split("\n")) {
111
+ if (!line.trim()) continue;
112
+ try {
113
+ out.push(JSON.parse(line) as HostEvent);
114
+ } catch {
115
+ // skip malformed lines
116
+ }
117
+ }
118
+ return out;
119
+ }
120
+
121
+ function eventTimestamp(e: HostEvent): string | null {
122
+ const ts = e.timestamp ?? e.detected_at;
123
+ return typeof ts === "string" ? ts : null;
124
+ }
125
+
126
+ function isAfterComposite(evt: HostEvent, cursor: string | undefined): boolean {
127
+ if (!cursor) return true;
128
+ const key = compositeKey(evt);
129
+ if (!key) return false;
130
+ return key > cursor;
131
+ }
132
+
133
+ export type PushOutcome = {
134
+ ungoverned_pushed: number;
135
+ tamper_pushed: number;
136
+ errors: string[];
137
+ };
138
+
139
+ export async function pushHostEvents(cwd: string): Promise<PushOutcome> {
140
+ const outcome: PushOutcome = {
141
+ ungoverned_pushed: 0,
142
+ tamper_pushed: 0,
143
+ errors: [],
144
+ };
145
+
146
+ const config = loadEnterpriseConfig(join(cwd, ".context"));
147
+ const apiKey = config.enterprise.api_key.trim();
148
+ const baseUrl = (config.enterprise.base_url || config.enterprise.endpoint).trim();
149
+ if (!apiKey || !baseUrl) {
150
+ outcome.errors.push("enterprise not configured");
151
+ return outcome;
152
+ }
153
+
154
+ const cursor = readCursor(cwd);
155
+ const ungovernedCursor = normalizeCursor(cursor.ungoverned_last_ts);
156
+ const tamperCursor = normalizeCursor(cursor.tamper_last_ts);
157
+ const ungoverned: HostEvent[] = [];
158
+ const tamper: HostEvent[] = [];
159
+
160
+ for (const file of listHostEventFiles(cwd)) {
161
+ for (const evt of readEventsFrom(file)) {
162
+ const ts = eventTimestamp(evt);
163
+ if (!ts) continue;
164
+ if (evt.event_type === "ungoverned_ai_session_detected" && isAfterComposite(evt, ungovernedCursor)) {
165
+ ungoverned.push(evt);
166
+ } else if (evt.event_type === "hook_tamper_detected" && isAfterComposite(evt, tamperCursor)) {
167
+ tamper.push(evt);
168
+ }
169
+ }
170
+ }
171
+
172
+ sortByTs(ungoverned);
173
+ sortByTs(tamper);
174
+
175
+ if (ungoverned.length > 0) {
176
+ const result = await pushBatch(
177
+ `${baseUrl.replace(/\/$/, "")}/api/v1/govern/ungoverned`,
178
+ apiKey,
179
+ {
180
+ events: ungoverned.map(toUngovernedPayload),
181
+ },
182
+ );
183
+ if (result.ok) {
184
+ outcome.ungoverned_pushed = ungoverned.length;
185
+ const last = ungoverned[ungoverned.length - 1];
186
+ cursor.ungoverned_last_ts = compositeKey(last) ?? cursor.ungoverned_last_ts;
187
+ } else {
188
+ outcome.errors.push(`ungoverned: ${result.error}`);
189
+ }
190
+ }
191
+
192
+ if (tamper.length > 0) {
193
+ const result = await pushBatch(
194
+ `${baseUrl.replace(/\/$/, "")}/api/v1/govern/tamper`,
195
+ apiKey,
196
+ {
197
+ events: tamper.map(toTamperPayload),
198
+ },
199
+ );
200
+ if (result.ok) {
201
+ outcome.tamper_pushed = tamper.length;
202
+ const last = tamper[tamper.length - 1];
203
+ cursor.tamper_last_ts = compositeKey(last) ?? cursor.tamper_last_ts;
204
+ } else {
205
+ outcome.errors.push(`tamper: ${result.error}`);
206
+ }
207
+ }
208
+
209
+ if (outcome.ungoverned_pushed > 0 || outcome.tamper_pushed > 0) {
210
+ writeCursor(cwd, cursor);
211
+ }
212
+
213
+ return outcome;
214
+ }
215
+
216
+ function toUngovernedPayload(e: HostEvent): Record<string, unknown> {
217
+ return {
218
+ detected_at: e.timestamp,
219
+ host_id: e.host_id,
220
+ cli: e.cli,
221
+ binary_path: e.binary,
222
+ args: e.args,
223
+ sys_user: e.user,
224
+ parent_pid: e.ppid,
225
+ pid: e.pid,
226
+ action_taken: e.action ?? "logged",
227
+ };
228
+ }
229
+
230
+ function toTamperPayload(e: HostEvent): Record<string, unknown> {
231
+ return {
232
+ detected_at: e.timestamp,
233
+ host_id: e.host_id,
234
+ cli: e.cli,
235
+ hook_name: e.hook_name ?? "any",
236
+ session_id: e.session_id,
237
+ last_seen: e.last_seen ?? null,
238
+ missing_seconds: e.missing_seconds,
239
+ };
240
+ }
241
+
242
+ async function pushBatch(
243
+ url: string,
244
+ apiKey: string,
245
+ payload: unknown,
246
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
247
+ try {
248
+ const res = await fetch(url, {
249
+ method: "POST",
250
+ headers: {
251
+ "Content-Type": "application/json",
252
+ Authorization: `Bearer ${apiKey}`,
253
+ },
254
+ body: JSON.stringify(payload),
255
+ });
256
+ if (!res.ok) {
257
+ return { ok: false, error: `HTTP ${res.status} ${res.statusText}` };
258
+ }
259
+ return { ok: true };
260
+ } catch (err) {
261
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
262
+ }
263
+ }
264
+
265
+ export type PusherHandle = {
266
+ stop(): void;
267
+ };
268
+
269
+ export function startHostEventsPusher(cwd: string, intervalMs: number): PusherHandle {
270
+ const tick = () => {
271
+ void pushHostEvents(cwd).catch((err) => {
272
+ process.stderr.write(
273
+ `[cortex-daemon] host-events push failed: ${err instanceof Error ? err.message : String(err)}\n`,
274
+ );
275
+ });
276
+ };
277
+ void Promise.resolve().then(tick);
278
+ const handle = setInterval(tick, intervalMs);
279
+ if (typeof handle.unref === "function") handle.unref();
280
+ return {
281
+ stop() {
282
+ clearInterval(handle);
283
+ },
284
+ };
285
+ }