embertime 0.0.0-stage → 0.1.1

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/ember-hook.mjs ADDED
@@ -0,0 +1,816 @@
1
+ #!/usr/bin/env node
2
+ // Ember agent hook: records AI-agent activity (Claude Code, Codex CLI, Cursor Agent) as
3
+ // Ember heartbeats.
4
+ //
5
+ // Usage: node ember-hook.mjs --agent claude-code|codex|cursor-agent (hook JSON on stdin)
6
+ //
7
+ // Guarantees:
8
+ // * zero dependencies, Node >= 18, ESM
9
+ // * never writes to stdout for Claude Code / Codex (they may interpret it); for
10
+ // Cursor it prints the minimal JSON reply Cursor expects. Always exits 0
11
+ // * never sends file contents, raw paths or prompt text: files are reduced
12
+ // to sha1(absolute path) and the project to the git root's basename
13
+ // * heartbeats are appended to ~/.ember/queue.jsonl before any network I/O,
14
+ // so nothing is lost when the server is down
15
+ //
16
+ // Environment overrides: EMBER_HOME (default ~/.ember), EMBER_API_URL, EMBER_API_KEY.
17
+
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import crypto from 'node:crypto';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ export const VERSION = '0.1.1';
25
+ export const AGENTS = ['claude-code', 'codex', 'cursor-agent'];
26
+
27
+ /** Events whose heartbeats are throttled: they fire on every tool call or file edit. */
28
+ const HIGH_FREQUENCY_EVENTS = new Set(['PostToolUse', 'afterFileEdit']);
29
+ export const DEFAULT_API_URL = 'https://ember-dev.vercel.app';
30
+
31
+ const QUEUE_CAP = 20_000; // max lines kept in queue.jsonl (oldest dropped)
32
+ const BATCH_SIZE = 500; // server maximum per request
33
+ const FETCH_TIMEOUT_MS = 2_500;
34
+ const FLUSH_BUDGET_MS = 6_000; // stop starting new batches after this long
35
+ const MAX_BATCHES_PER_RUN = 10;
36
+ const LOCK_STALE_MS = 10_000;
37
+ const LOCK_WAIT_MS = 1_000;
38
+ const STDIN_TIMEOUT_MS = 1_000;
39
+ const THROTTLE_SECONDS = 30;
40
+ const BACKOFF_MIN_S = 30;
41
+ const BACKOFF_MAX_S = 600;
42
+ const LOG_MAX_BYTES = 1024 * 1024;
43
+ const HARD_EXIT_MS = 9_000; // absolute safety net for the whole process
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Paths and config
47
+ // ---------------------------------------------------------------------------
48
+
49
+ export function emberHome(env = process.env) {
50
+ return env.EMBER_HOME || path.join(os.homedir(), '.ember');
51
+ }
52
+
53
+ function paths(home) {
54
+ return {
55
+ config: path.join(home, 'config.json'),
56
+ queue: path.join(home, 'queue.jsonl'),
57
+ inflight: path.join(home, 'queue.inflight.jsonl'),
58
+ lock: path.join(home, 'queue.lock'),
59
+ state: path.join(home, 'hook-state.json'),
60
+ log: path.join(home, 'hook.log'),
61
+ lastError: path.join(home, 'last_error'),
62
+ };
63
+ }
64
+
65
+ function readJson(file, fallback) {
66
+ try {
67
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
68
+ } catch {
69
+ return fallback;
70
+ }
71
+ }
72
+
73
+ /** Resolve API URL + key: env overrides win over ~/.ember/config.json. */
74
+ export function loadConfig(home, env = process.env) {
75
+ const cfg = readJson(paths(home).config, {}) || {};
76
+ const apiUrl = String(env.EMBER_API_URL || cfg.api_url || DEFAULT_API_URL).replace(/\/+$/, '');
77
+ const apiKey = env.EMBER_API_KEY || cfg.api_key || '';
78
+ return { apiUrl, apiKey };
79
+ }
80
+
81
+ function writeAtomic(file, data) {
82
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
83
+ fs.writeFileSync(tmp, data);
84
+ fs.renameSync(tmp, file);
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Logging (to ~/.ember/hook.log, rotated at 1 MB; never stdout)
89
+ // ---------------------------------------------------------------------------
90
+
91
+ export function log(home, level, message) {
92
+ try {
93
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
94
+ const file = paths(home).log;
95
+ try {
96
+ if (fs.statSync(file).size > LOG_MAX_BYTES) fs.renameSync(file, `${file}.1`);
97
+ } catch {
98
+ /* no log yet */
99
+ }
100
+ fs.appendFileSync(file, `${new Date().toISOString()} [${level}] ${message}\n`);
101
+ } catch {
102
+ /* logging must never fail the hook */
103
+ }
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Git: root, project name, branch (reads .git/HEAD directly, no git CLI)
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /** Walk up from `start` until a directory containing `.git` (dir or file) is found. */
111
+ export function findGitRoot(start) {
112
+ if (!start) return null;
113
+ let dir = path.resolve(start);
114
+ for (;;) {
115
+ if (fs.existsSync(path.join(dir, '.git'))) return dir;
116
+ const parent = path.dirname(dir);
117
+ if (parent === dir) return null;
118
+ dir = parent;
119
+ }
120
+ }
121
+
122
+ /** Branch name from HEAD, or undefined for detached HEAD / unreadable repos. */
123
+ export function readBranch(gitRoot) {
124
+ try {
125
+ let gitDir = path.join(gitRoot, '.git');
126
+ if (fs.statSync(gitDir).isFile()) {
127
+ // Worktrees and submodules: ".git" is a file containing "gitdir: <path>".
128
+ const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(gitDir, 'utf8'));
129
+ if (!m) return undefined;
130
+ gitDir = path.resolve(gitRoot, m[1].trim());
131
+ }
132
+ const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
133
+ const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
134
+ return ref ? ref[1] : undefined;
135
+ } catch {
136
+ return undefined;
137
+ }
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Language by file extension
142
+ // ---------------------------------------------------------------------------
143
+
144
+ const LANGUAGE_BY_EXT = {
145
+ '.ts': 'TypeScript', '.mts': 'TypeScript', '.cts': 'TypeScript', '.tsx': 'TSX',
146
+ '.js': 'JavaScript', '.mjs': 'JavaScript', '.cjs': 'JavaScript', '.jsx': 'JSX',
147
+ '.py': 'Python', '.pyi': 'Python', '.ipynb': 'Jupyter Notebook',
148
+ '.rs': 'Rust', '.go': 'Go', '.java': 'Java', '.kt': 'Kotlin', '.kts': 'Kotlin',
149
+ '.swift': 'Swift', '.m': 'Objective-C', '.c': 'C', '.h': 'C',
150
+ '.cpp': 'C++', '.cc': 'C++', '.cxx': 'C++', '.hpp': 'C++', '.hh': 'C++',
151
+ '.cs': 'C#', '.fs': 'F#', '.rb': 'Ruby', '.php': 'PHP', '.dart': 'Dart',
152
+ '.scala': 'Scala', '.lua': 'Lua', '.pl': 'Perl', '.r': 'R', '.jl': 'Julia',
153
+ '.ex': 'Elixir', '.exs': 'Elixir', '.erl': 'Erlang', '.hs': 'Haskell',
154
+ '.clj': 'Clojure', '.ml': 'OCaml', '.zig': 'Zig', '.nim': 'Nim',
155
+ '.sh': 'Shell', '.bash': 'Shell', '.zsh': 'Shell', '.fish': 'Shell', '.ps1': 'PowerShell',
156
+ '.sql': 'SQL', '.html': 'HTML', '.htm': 'HTML', '.css': 'CSS', '.scss': 'SCSS',
157
+ '.sass': 'Sass', '.less': 'Less', '.vue': 'Vue', '.svelte': 'Svelte', '.astro': 'Astro',
158
+ '.json': 'JSON', '.jsonc': 'JSON', '.yaml': 'YAML', '.yml': 'YAML', '.toml': 'TOML',
159
+ '.xml': 'XML', '.md': 'Markdown', '.mdx': 'MDX', '.tf': 'HCL', '.proto': 'Protocol Buffers',
160
+ '.graphql': 'GraphQL', '.gql': 'GraphQL', '.prisma': 'Prisma', '.sol': 'Solidity',
161
+ };
162
+
163
+ const LANGUAGE_BY_BASENAME = {
164
+ dockerfile: 'Docker', makefile: 'Makefile', 'cmakelists.txt': 'CMake', gemfile: 'Ruby',
165
+ };
166
+
167
+ export function languageForPath(file) {
168
+ const base = path.basename(file).toLowerCase();
169
+ if (LANGUAGE_BY_BASENAME[base]) return LANGUAGE_BY_BASENAME[base];
170
+ if (base.startsWith('dockerfile')) return 'Docker';
171
+ return LANGUAGE_BY_EXT[path.extname(base)];
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Tech-stack detection (dependency-free copy of extensions/vscode/src/stack.ts)
176
+ // ---------------------------------------------------------------------------
177
+
178
+ // Order matters: frameworks first, languages last (e.g. Next.js, React, Tailwind CSS, TypeScript).
179
+ const NPM_STACK = [
180
+ { label: 'Next.js', deps: ['next'] },
181
+ { label: 'Nuxt', deps: ['nuxt'] },
182
+ { label: 'SvelteKit', deps: ['@sveltejs/kit'] },
183
+ { label: 'Astro', deps: ['astro'] },
184
+ { label: 'Remix', prefixes: ['@remix-run/'] },
185
+ { label: 'React', deps: ['react'] },
186
+ { label: 'React Native', deps: ['react-native'] },
187
+ { label: 'Expo', deps: ['expo'] },
188
+ { label: 'Vue', deps: ['vue'] },
189
+ { label: 'Svelte', deps: ['svelte'] },
190
+ { label: 'Angular', deps: ['@angular/core', 'angular'] },
191
+ { label: 'SolidJS', deps: ['solid-js'] },
192
+ { label: 'Electron', deps: ['electron'] },
193
+ { label: 'Three.js', deps: ['three'] },
194
+ { label: 'Express', deps: ['express'] },
195
+ { label: 'NestJS', deps: ['@nestjs/core', 'nestjs'] },
196
+ { label: 'Fastify', deps: ['fastify'] },
197
+ { label: 'Hono', deps: ['hono'] },
198
+ { label: 'Prisma', deps: ['prisma', '@prisma/client'] },
199
+ { label: 'Tailwind CSS', deps: ['tailwindcss'] },
200
+ { label: 'Vite', deps: ['vite'] },
201
+ { label: 'TypeScript', deps: ['typescript'] },
202
+ ];
203
+
204
+ const PYTHON_STACK = [
205
+ ['django', 'Django'], ['flask', 'Flask'], ['fastapi', 'FastAPI'], ['torch', 'PyTorch'],
206
+ ['tensorflow', 'TensorFlow'], ['pandas', 'pandas'], ['numpy', 'NumPy'],
207
+ ];
208
+ const PYTHON_FILES = ['pyproject.toml', 'requirements.txt', 'setup.py', 'setup.cfg', 'Pipfile'];
209
+
210
+ const RUST_STACK = [
211
+ ['axum', 'Axum'], ['actix-web', 'Actix'], ['bevy', 'Bevy'], ['tauri', 'Tauri'], ['tokio', 'Tokio'],
212
+ ];
213
+
214
+ function readText(dir, name) {
215
+ try {
216
+ return fs.readFileSync(path.join(dir, name), 'utf8');
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+
222
+ function escapeRe(s) {
223
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
224
+ }
225
+
226
+ /** Python dependency mentioned as a requirement / pyproject entry (not a substring like "torchvision"). */
227
+ function hasPythonDep(text, name) {
228
+ return new RegExp(`(^|[\\s"'\\[,])${escapeRe(name)}(?=$|[\\s"'=<>~!\\[;,@])`, 'im').test(text);
229
+ }
230
+
231
+ /** Cargo dependency: `name = ...` or `[dependencies.name]`. */
232
+ function hasCargoDep(text, name) {
233
+ return new RegExp(`^\\s*(?:\\[[\\w.-]*dependencies\\.)?${escapeRe(name)}\\s*[=\\]]`, 'm').test(text);
234
+ }
235
+
236
+ /**
237
+ * Detect the tech stack of a project directory by inspecting manifest files
238
+ * in that directory only (no recursion). Returns display names, deduplicated.
239
+ */
240
+ export function detectStack(dir) {
241
+ const out = [];
242
+ const add = (label) => {
243
+ if (!out.includes(label)) out.push(label);
244
+ };
245
+ let entries = [];
246
+ try {
247
+ entries = fs.readdirSync(dir);
248
+ } catch {
249
+ return out;
250
+ }
251
+ const has = (name) => entries.includes(name);
252
+
253
+ // JavaScript / TypeScript
254
+ const pkgText = readText(dir, 'package.json');
255
+ if (pkgText) {
256
+ let pkg = {};
257
+ try {
258
+ pkg = JSON.parse(pkgText) || {};
259
+ } catch {
260
+ /* malformed package.json */
261
+ }
262
+ const deps = Object.keys({
263
+ ...(pkg.dependencies || {}),
264
+ ...(pkg.devDependencies || {}),
265
+ ...(pkg.peerDependencies || {}),
266
+ });
267
+ for (const entry of NPM_STACK) {
268
+ const hit =
269
+ (entry.deps || []).some((d) => deps.includes(d)) ||
270
+ (entry.prefixes || []).some((p) => deps.some((d) => d.startsWith(p)));
271
+ if (hit) add(entry.label);
272
+ }
273
+ }
274
+ if (has('tsconfig.json')) add('TypeScript');
275
+
276
+ // Rust (including Tauri apps whose Rust side lives in src-tauri/)
277
+ const cargo = readText(dir, 'Cargo.toml');
278
+ if (cargo !== null) {
279
+ for (const [dep, label] of RUST_STACK) if (hasCargoDep(cargo, dep)) add(label);
280
+ add('Rust');
281
+ }
282
+ if (readText(path.join(dir, 'src-tauri'), 'Cargo.toml') !== null) {
283
+ add('Tauri');
284
+ add('Rust');
285
+ }
286
+
287
+ // Python
288
+ const pyTexts = PYTHON_FILES.map((f) => readText(dir, f)).filter((t) => t !== null);
289
+ if (pyTexts.length) {
290
+ const all = pyTexts.join('\n');
291
+ for (const [dep, label] of PYTHON_STACK) if (hasPythonDep(all, dep)) add(label);
292
+ add('Python');
293
+ }
294
+
295
+ // Go
296
+ const gomod = readText(dir, 'go.mod');
297
+ if (gomod !== null) {
298
+ if (gomod.includes('github.com/gin-gonic/gin')) add('Gin');
299
+ if (gomod.includes('github.com/gofiber/fiber')) add('Fiber');
300
+ add('Go');
301
+ }
302
+
303
+ // JVM
304
+ const jvm = ['pom.xml', 'build.gradle', 'build.gradle.kts']
305
+ .map((f) => readText(dir, f))
306
+ .filter((t) => t !== null)
307
+ .join('\n');
308
+ if (jvm) {
309
+ if (/org\.springframework|spring-boot/.test(jvm)) add('Spring');
310
+ add(/kotlin/i.test(jvm) ? 'Kotlin' : 'Java');
311
+ }
312
+
313
+ // Ruby
314
+ const gemfile = readText(dir, 'Gemfile');
315
+ if (gemfile !== null) {
316
+ if (/^\s*gem\s+['"]rails['"]/m.test(gemfile)) add('Rails');
317
+ add('Ruby');
318
+ }
319
+
320
+ // PHP
321
+ const composer = readText(dir, 'composer.json');
322
+ if (composer !== null) {
323
+ if (composer.includes('"laravel/framework"')) add('Laravel');
324
+ add('PHP');
325
+ }
326
+
327
+ // Dart / Flutter
328
+ const pubspec = readText(dir, 'pubspec.yaml');
329
+ if (pubspec !== null) {
330
+ if (/^\s*flutter\s*:/m.test(pubspec)) add('Flutter');
331
+ add('Dart');
332
+ }
333
+
334
+ // .NET
335
+ if (entries.some((e) => e.endsWith('.csproj') || e.endsWith('.sln'))) {
336
+ add('C#');
337
+ add('.NET');
338
+ }
339
+
340
+ // C++
341
+ if (has('CMakeLists.txt')) add('C++');
342
+
343
+ // Docker
344
+ if (
345
+ entries.some((e) => /^dockerfile/i.test(e)) ||
346
+ ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'].some(has)
347
+ ) {
348
+ add('Docker');
349
+ }
350
+
351
+ return out;
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Hook payload -> heartbeat
356
+ // ---------------------------------------------------------------------------
357
+
358
+ const WRITE_TOOLS = new Set(['write', 'edit', 'multiedit', 'notebookedit', 'apply_patch', 'applypatch']);
359
+
360
+ export function isWriteTool(toolName) {
361
+ return typeof toolName === 'string' && WRITE_TOOLS.has(toolName.toLowerCase());
362
+ }
363
+
364
+ /** All file paths named in an apply_patch body ("*** Update File: path", Add/Delete/Move to). */
365
+ export function parseApplyPatchPaths(text) {
366
+ if (typeof text !== 'string') return [];
367
+ const out = [];
368
+ const re = /^\*\*\* (?:Update File|Add File|Delete File|Move to):\s*(.+?)\s*$/gm;
369
+ let m;
370
+ while ((m = re.exec(text))) out.push(m[1]);
371
+ return out;
372
+ }
373
+
374
+ /** Collect strings that look like patches from an arbitrarily shaped tool_input. */
375
+ function patchTexts(value, depth = 0) {
376
+ if (depth > 4 || value == null) return [];
377
+ if (typeof value === 'string') return value.includes('*** ') ? [value] : [];
378
+ if (Array.isArray(value)) return value.flatMap((v) => patchTexts(v, depth + 1));
379
+ if (typeof value === 'object') return Object.values(value).flatMap((v) => patchTexts(v, depth + 1));
380
+ return [];
381
+ }
382
+
383
+ /**
384
+ * Absolute path of the file a tool touched, if any. Uses explicit path fields
385
+ * first (Claude's Write/Edit/Read/NotebookEdit), then apply_patch text (Codex).
386
+ * `path` alone is deliberately ignored: for Grep/Glob it is a directory.
387
+ */
388
+ export function extractFilePath(toolInput, cwd) {
389
+ if (toolInput == null) return undefined;
390
+ let p;
391
+ if (typeof toolInput === 'object' && !Array.isArray(toolInput)) {
392
+ for (const key of ['file_path', 'notebook_path', 'filePath']) {
393
+ if (typeof toolInput[key] === 'string' && toolInput[key].trim()) {
394
+ p = toolInput[key].trim();
395
+ break;
396
+ }
397
+ }
398
+ }
399
+ if (!p) {
400
+ for (const text of patchTexts(toolInput)) {
401
+ const found = parseApplyPatchPaths(text)[0];
402
+ if (found) {
403
+ p = found;
404
+ break;
405
+ }
406
+ }
407
+ }
408
+ if (!p) return undefined;
409
+ return path.resolve(cwd || process.cwd(), p);
410
+ }
411
+
412
+ export function sha1(text) {
413
+ return crypto.createHash('sha1').update(text).digest('hex');
414
+ }
415
+
416
+ export function parsePayload(text) {
417
+ if (!text || !text.trim()) return {};
418
+ try {
419
+ const v = JSON.parse(text);
420
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
421
+ } catch {
422
+ return {};
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Build exactly one heartbeat from a hook payload. Only derived, non-sensitive
428
+ * fields are copied: never prompt text, tool output, file contents or paths.
429
+ */
430
+ export function buildHeartbeat(payload, { agent, now = Date.now() / 1000, env = process.env } = {}) {
431
+ // Claude Code and Codex send `cwd`; Cursor sends `workspace_roots` (and sets CURSOR_PROJECT_DIR).
432
+ const roots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots.filter((r) => typeof r === 'string' && r) : [];
433
+ const cwd =
434
+ (typeof payload.cwd === 'string' && payload.cwd) || roots[0] || env.CURSOR_PROJECT_DIR || process.cwd();
435
+ const root = findGitRoot(cwd);
436
+ const projectDir = root || path.resolve(cwd);
437
+ const hb = {
438
+ time: now,
439
+ project: path.basename(projectDir) || 'unknown',
440
+ editor: agent,
441
+ category: 'ai',
442
+ is_write: isWriteTool(payload.tool_name) || payload.hook_event_name === 'afterFileEdit',
443
+ };
444
+ if (root) {
445
+ const branch = readBranch(root);
446
+ if (branch) hb.branch = branch;
447
+ }
448
+ // Cursor's afterFileEdit puts the edited file at the top level.
449
+ const file =
450
+ extractFilePath(payload.tool_input, cwd) ||
451
+ (typeof payload.file_path === 'string' && payload.file_path.trim() ? path.resolve(cwd, payload.file_path.trim()) : undefined);
452
+ if (file) {
453
+ hb.entity = sha1(file);
454
+ const language = languageForPath(file);
455
+ if (language) hb.language = language;
456
+ }
457
+ const stack = detectStack(projectDir);
458
+ if (stack.length) hb.stack = stack;
459
+ return hb;
460
+ }
461
+
462
+ // ---------------------------------------------------------------------------
463
+ // State (throttle + backoff) in hook-state.json
464
+ // ---------------------------------------------------------------------------
465
+
466
+ export function readState(home) {
467
+ const s = readJson(paths(home).state, {});
468
+ return s && typeof s === 'object' ? { last: {}, ...s } : { last: {} };
469
+ }
470
+
471
+ export function writeState(home, state) {
472
+ try {
473
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
474
+ writeAtomic(paths(home).state, JSON.stringify(state));
475
+ } catch (e) {
476
+ log(home, 'warn', `could not write state: ${e.message}`);
477
+ }
478
+ }
479
+
480
+ /** PostToolUse heartbeats for the same agent+project are throttled to one per 30s. */
481
+ export function shouldThrottle(state, hb, eventName) {
482
+ if (!HIGH_FREQUENCY_EVENTS.has(eventName)) return false;
483
+ const last = state.last?.[`${hb.editor}|${hb.project}`];
484
+ return typeof last === 'number' && hb.time - last < THROTTLE_SECONDS;
485
+ }
486
+
487
+ // ---------------------------------------------------------------------------
488
+ // Queue with lock file
489
+ // ---------------------------------------------------------------------------
490
+ //
491
+ // queue.jsonl pending heartbeats, one JSON per line (append-only by hooks)
492
+ // queue.inflight.jsonl lines claimed by the hook currently sending
493
+ // queue.lock short-lived mutex around every file mutation above
494
+ //
495
+ // The lock is only held for millisecond file operations, never across the
496
+ // network. "Only one sender at a time" is enforced by the inflight file: a
497
+ // fresh inflight file means another hook is sending, so we skip; an inflight
498
+ // file older than 10s belongs to a crashed hook and is reclaimed.
499
+
500
+ function sleepSync(ms) {
501
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
502
+ }
503
+
504
+ function tryLock(lockPath) {
505
+ for (let attempt = 0; attempt < 2; attempt++) {
506
+ try {
507
+ const fd = fs.openSync(lockPath, 'wx');
508
+ fs.writeSync(fd, `${process.pid} ${Date.now()}`);
509
+ fs.closeSync(fd);
510
+ return true;
511
+ } catch (e) {
512
+ if (e.code !== 'EEXIST') throw e;
513
+ try {
514
+ if (Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS) {
515
+ fs.unlinkSync(lockPath); // stale: holder crashed; retry once
516
+ continue;
517
+ }
518
+ } catch {
519
+ continue; // vanished between open and stat; retry
520
+ }
521
+ return false;
522
+ }
523
+ }
524
+ return false;
525
+ }
526
+
527
+ /** Run fn while holding queue.lock. If the lock can't be had within waitMs, run unlocked (never lose data). */
528
+ export function withLock(home, fn, waitMs = LOCK_WAIT_MS) {
529
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
530
+ const lockPath = paths(home).lock;
531
+ const start = Date.now();
532
+ let locked = false;
533
+ while (!(locked = tryLock(lockPath))) {
534
+ if (Date.now() - start > waitMs) break;
535
+ sleepSync(10);
536
+ }
537
+ if (!locked) log(home, 'warn', 'queue.lock busy; proceeding without lock');
538
+ try {
539
+ return fn(locked);
540
+ } finally {
541
+ if (locked) {
542
+ try {
543
+ fs.unlinkSync(lockPath);
544
+ } catch {
545
+ /* already gone */
546
+ }
547
+ }
548
+ }
549
+ }
550
+
551
+ function readLines(file) {
552
+ try {
553
+ return fs
554
+ .readFileSync(file, 'utf8')
555
+ .split('\n')
556
+ .filter((l) => l.trim());
557
+ } catch {
558
+ return [];
559
+ }
560
+ }
561
+
562
+ function capLines(lines) {
563
+ return lines.length > QUEUE_CAP ? lines.slice(lines.length - QUEUE_CAP) : lines;
564
+ }
565
+
566
+ function writeLines(file, lines) {
567
+ writeAtomic(file, lines.length ? `${lines.join('\n')}\n` : '');
568
+ }
569
+
570
+ export function appendToQueue(home, heartbeat) {
571
+ const { queue } = paths(home);
572
+ withLock(home, () => {
573
+ fs.appendFileSync(queue, `${JSON.stringify(heartbeat)}\n`, { mode: 0o600 });
574
+ // Cheap size check first (a heartbeat line is always > 50 bytes); only count lines when the cap could be exceeded.
575
+ if (fs.statSync(queue).size > QUEUE_CAP * 50) {
576
+ const lines = readLines(queue);
577
+ if (lines.length > QUEUE_CAP) writeLines(queue, capLines(lines));
578
+ }
579
+ });
580
+ }
581
+
582
+ export function queueLength(home) {
583
+ const p = paths(home);
584
+ return readLines(p.queue).length + readLines(p.inflight).length;
585
+ }
586
+
587
+ export async function postHeartbeats(apiUrl, apiKey, heartbeats, timeoutMs = FETCH_TIMEOUT_MS) {
588
+ try {
589
+ const res = await fetch(`${apiUrl}/api/v1/heartbeats`, {
590
+ method: 'POST',
591
+ headers: {
592
+ Authorization: `Bearer ${apiKey}`,
593
+ 'Content-Type': 'application/json',
594
+ 'User-Agent': `ember-agent-hook/${VERSION}`,
595
+ },
596
+ body: JSON.stringify({ heartbeats }),
597
+ signal: AbortSignal.timeout(timeoutMs),
598
+ });
599
+ const body = await res.text().catch(() => '');
600
+ return { status: res.status, body };
601
+ } catch (e) {
602
+ return { status: 0, body: '', error: e?.name === 'TimeoutError' ? 'timeout' : String(e?.message || e) };
603
+ }
604
+ }
605
+
606
+ function backoffSeconds(failures) {
607
+ return Math.min(BACKOFF_MAX_S, BACKOFF_MIN_S * 2 ** Math.max(0, failures - 1));
608
+ }
609
+
610
+ /**
611
+ * Send queued heartbeats in batches of 500. Returns a summary object:
612
+ * { status: 'ok'|'skipped'|'unauthorized'|'retry'|'empty'|'no-key', sent, dropped, remaining }
613
+ */
614
+ export async function flushQueue(home, { apiUrl, apiKey, force = false, now = Date.now() } = {}) {
615
+ const p = paths(home);
616
+ if (!apiKey) {
617
+ writeLastError(home, `no API key configured (set api_key in ${p.config} or EMBER_API_KEY)`);
618
+ return { status: 'no-key', sent: 0, dropped: 0, remaining: queueLength(home) };
619
+ }
620
+
621
+ const state = readState(home);
622
+ if (!force && state.backoff && now < state.backoff.next_at) {
623
+ return { status: 'skipped', reason: 'backoff', sent: 0, dropped: 0, remaining: queueLength(home) };
624
+ }
625
+
626
+ // Claim everything pending into the inflight file.
627
+ const claimed = withLock(home, () => {
628
+ let inflightLines = [];
629
+ try {
630
+ const age = now - fs.statSync(p.inflight).mtimeMs;
631
+ if (age < LOCK_STALE_MS) return null; // another hook is sending right now
632
+ inflightLines = readLines(p.inflight); // abandoned by a crashed hook: reclaim
633
+ } catch {
634
+ /* no inflight file */
635
+ }
636
+ const lines = inflightLines.concat(readLines(p.queue));
637
+ if (!lines.length) {
638
+ fs.rmSync(p.inflight, { force: true });
639
+ return [];
640
+ }
641
+ writeLines(p.inflight, lines);
642
+ writeLines(p.queue, []);
643
+ return lines;
644
+ });
645
+ if (claimed === null) return { status: 'skipped', reason: 'busy', sent: 0, dropped: 0, remaining: queueLength(home) };
646
+ if (!claimed.length) return { status: 'empty', sent: 0, dropped: 0, remaining: 0 };
647
+
648
+ let remaining = claimed;
649
+ let sent = 0;
650
+ let dropped = 0;
651
+ let status = 'ok';
652
+ const started = Date.now();
653
+ try {
654
+ for (let i = 0; remaining.length && i < MAX_BATCHES_PER_RUN; i++) {
655
+ if (Date.now() - started > FLUSH_BUDGET_MS) break;
656
+ const batchLines = remaining.slice(0, BATCH_SIZE);
657
+ const batch = [];
658
+ for (const line of batchLines) {
659
+ try {
660
+ batch.push(JSON.parse(line));
661
+ } catch {
662
+ dropped++; // corrupt line: drop it
663
+ }
664
+ }
665
+ const res = batch.length ? await postHeartbeats(apiUrl, apiKey, batch) : { status: 202 };
666
+ if (res.status >= 200 && res.status < 300) {
667
+ sent += batch.length;
668
+ } else if (res.status === 400) {
669
+ dropped += batch.length; // invalid: never retry
670
+ log(home, 'warn', `server rejected batch of ${batch.length} (400): ${res.body.slice(0, 300)}`);
671
+ } else if (res.status === 401) {
672
+ status = 'unauthorized';
673
+ writeLastError(home, `401 Unauthorized from ${apiUrl}: check api_key in ${p.config}`);
674
+ log(home, 'error', `401 from ${apiUrl}; keeping ${remaining.length} queued heartbeats`);
675
+ break;
676
+ } else {
677
+ status = 'retry';
678
+ log(home, 'warn', `flush failed (${res.status || res.error}); will retry later`);
679
+ break;
680
+ }
681
+ remaining = remaining.slice(batchLines.length);
682
+ writeLines(p.inflight, remaining); // progress checkpoint (also refreshes mtime)
683
+ }
684
+ } finally {
685
+ // Return unsent lines to the front of the queue, ahead of anything appended meanwhile.
686
+ withLock(home, () => {
687
+ const merged = capLines(remaining.concat(readLines(p.queue)));
688
+ writeLines(p.queue, merged);
689
+ fs.rmSync(p.inflight, { force: true });
690
+ });
691
+ }
692
+
693
+ const nextState = readState(home);
694
+ if (status === 'retry') {
695
+ const failures = (nextState.backoff?.failures || 0) + 1;
696
+ nextState.backoff = { failures, next_at: Date.now() + backoffSeconds(failures) * 1000 };
697
+ } else {
698
+ delete nextState.backoff;
699
+ }
700
+ writeState(home, nextState);
701
+ if (status === 'ok') fs.rmSync(p.lastError, { force: true });
702
+
703
+ return { status, sent, dropped, remaining: queueLength(home) };
704
+ }
705
+
706
+ function writeLastError(home, message) {
707
+ try {
708
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
709
+ fs.writeFileSync(paths(home).lastError, `${new Date().toISOString()} ${message}\n`);
710
+ } catch {
711
+ /* ignore */
712
+ }
713
+ }
714
+
715
+ // ---------------------------------------------------------------------------
716
+ // Entry point
717
+ // ---------------------------------------------------------------------------
718
+
719
+ export function parseArgs(argv) {
720
+ let agent;
721
+ for (let i = 0; i < argv.length; i++) {
722
+ const a = argv[i];
723
+ if (a === '--agent') agent = argv[++i];
724
+ else if (a.startsWith('--agent=')) agent = a.slice('--agent='.length);
725
+ }
726
+ return { agent: AGENTS.includes(agent) ? agent : undefined, rawAgent: agent };
727
+ }
728
+
729
+ /** Read all of stdin, giving up after timeoutMs (agents always close stdin, but be safe). */
730
+ export function readStdin(timeoutMs = STDIN_TIMEOUT_MS, stream = process.stdin) {
731
+ return new Promise((resolve) => {
732
+ if (stream.isTTY) return resolve('');
733
+ const chunks = [];
734
+ let done = false;
735
+ const finish = () => {
736
+ if (done) return;
737
+ done = true;
738
+ clearTimeout(timer);
739
+ stream.removeAllListeners('data');
740
+ stream.pause();
741
+ resolve(Buffer.concat(chunks).toString('utf8'));
742
+ };
743
+ const timer = setTimeout(finish, timeoutMs);
744
+ stream.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)));
745
+ stream.on('end', finish);
746
+ stream.on('error', finish);
747
+ });
748
+ }
749
+
750
+ /** Process one hook invocation. Exported for tests; never throws. */
751
+ export async function runHook({ argv = [], env = process.env, stdinText = '' } = {}) {
752
+ const home = emberHome(env);
753
+ try {
754
+ const { agent, rawAgent } = parseArgs(argv);
755
+ if (!agent) {
756
+ log(home, 'error', `missing or unknown --agent ${JSON.stringify(rawAgent)} (expected ${AGENTS.join('|')})`);
757
+ return { status: 'bad-args' };
758
+ }
759
+ const payload = parsePayload(stdinText);
760
+ const event = typeof payload.hook_event_name === 'string' ? payload.hook_event_name : '';
761
+ const hb = buildHeartbeat(payload, { agent, env });
762
+
763
+ const state = readState(home);
764
+ if (shouldThrottle(state, hb, event)) return { status: 'throttled' };
765
+ state.last = { ...(state.last || {}), [`${hb.editor}|${hb.project}`]: hb.time };
766
+ writeState(home, state);
767
+
768
+ appendToQueue(home, hb);
769
+ const { apiUrl, apiKey } = loadConfig(home, env);
770
+ return await flushQueue(home, { apiUrl, apiKey });
771
+ } catch (e) {
772
+ log(home, 'error', `hook failed: ${e?.stack || e}`);
773
+ return { status: 'error' };
774
+ }
775
+ }
776
+
777
+ async function main() {
778
+ const home = emberHome();
779
+ const bail = (e) => {
780
+ if (e) log(home, 'error', `fatal: ${e?.stack || e}`);
781
+ process.exit(0);
782
+ };
783
+ process.on('uncaughtException', bail);
784
+ process.on('unhandledRejection', bail);
785
+ setTimeout(() => {
786
+ log(home, 'warn', 'hard timeout reached; exiting');
787
+ process.exit(0);
788
+ }, HARD_EXIT_MS).unref();
789
+
790
+ const argv = process.argv.slice(2);
791
+ const stdinText = await readStdin();
792
+ await runHook({ argv, stdinText });
793
+ const reply = cursorReply(parseArgs(argv).agent, parsePayload(stdinText));
794
+ if (reply) process.stdout.write(reply);
795
+ process.exit(0);
796
+ }
797
+
798
+ /**
799
+ * Cursor reads a hook's stdout as its JSON reply. Ember only observes, so it
800
+ * answers with the neutral reply: let the prompt continue, change nothing.
801
+ * Claude Code and Codex get no output at all.
802
+ */
803
+ export function cursorReply(agent, payload) {
804
+ if (agent !== 'cursor-agent') return '';
805
+ return payload.hook_event_name === 'beforeSubmitPrompt' ? '{"continue":true}' : '{}';
806
+ }
807
+
808
+ function isMain() {
809
+ try {
810
+ return fs.realpathSync(process.argv[1] || '') === fs.realpathSync(fileURLToPath(import.meta.url));
811
+ } catch {
812
+ return false;
813
+ }
814
+ }
815
+
816
+ if (isMain()) main();