embertime 0.1.4 → 0.1.5

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 (2) hide show
  1. package/ember-hook.mjs +99 -16
  2. package/package.json +1 -1
package/ember-hook.mjs CHANGED
@@ -14,15 +14,17 @@
14
14
  // * heartbeats are appended to ~/.ember/queue.jsonl before any network I/O,
15
15
  // so nothing is lost when the server is down
16
16
  //
17
- // Environment overrides: EMBER_HOME (default ~/.ember), EMBER_API_URL, EMBER_API_KEY.
17
+ // Environment overrides: EMBER_HOME (default ~/.ember), EMBER_API_URL, EMBER_API_KEY,
18
+ // EMBER_SEND_BRANCH=0 (don't send branch names).
18
19
 
19
20
  import fs from 'node:fs';
20
21
  import os from 'node:os';
21
22
  import path from 'node:path';
22
23
  import crypto from 'node:crypto';
24
+ import { execFileSync } from 'node:child_process';
23
25
  import { fileURLToPath } from 'node:url';
24
26
 
25
- export const VERSION = '0.1.4';
27
+ export const VERSION = '0.1.5';
26
28
  export const AGENTS = ['claude-code', 'codex', 'cursor-agent'];
27
29
 
28
30
  /** Events whose heartbeats are throttled: they fire on every tool call or file edit. */
@@ -72,12 +74,16 @@ function readJson(file, fallback) {
72
74
  }
73
75
  }
74
76
 
75
- /** Resolve API URL + key: env overrides win over ~/.ember/config.json. */
77
+ /**
78
+ * Resolve API URL + key: env overrides win over ~/.ember/config.json.
79
+ * `send_branch: false` (or EMBER_SEND_BRANCH=0) keeps branch names on this machine.
80
+ */
76
81
  export function loadConfig(home, env = process.env) {
77
82
  const cfg = readJson(paths(home).config, {}) || {};
78
83
  const apiUrl = String(env.EMBER_API_URL || cfg.api_url || DEFAULT_API_URL).replace(/\/+$/, '');
79
84
  const apiKey = env.EMBER_API_KEY || cfg.api_key || '';
80
- return { apiUrl, apiKey };
85
+ const sendBranch = env.EMBER_SEND_BRANCH !== undefined ? env.EMBER_SEND_BRANCH !== '0' : cfg.send_branch !== false;
86
+ return { apiUrl, apiKey, sendBranch };
81
87
  }
82
88
 
83
89
  function writeAtomic(file, data) {
@@ -363,6 +369,56 @@ export function isWriteTool(toolName) {
363
369
  return typeof toolName === 'string' && WRITE_TOOLS.has(toolName.toLowerCase());
364
370
  }
365
371
 
372
+ /** Shell tools (Claude Code's Bash, Codex's shell). They can write files without naming them. */
373
+ const SHELL_TOOLS = new Set(['bash', 'shell', 'local_shell', 'exec_command']);
374
+
375
+ export function isShellTool(toolName) {
376
+ return typeof toolName === 'string' && SHELL_TOOLS.has(toolName.toLowerCase());
377
+ }
378
+
379
+ const SHELL_LOOKBACK_S = 600; // never credit a shell command with edits older than this
380
+ const SHELL_FIRST_LOOKBACK_S = 120; // when there is no earlier event to measure from
381
+ const GIT_TIMEOUT_MS = 1_500;
382
+ const MAX_CHANGED_FILES = 5_000;
383
+
384
+ /**
385
+ * The file a shell command most likely wrote: among files git reports as
386
+ * modified, or new and not ignored, under `root`, the one changed most recently
387
+ * after `sinceSec`. Ignored paths (node_modules, build output) never count, and
388
+ * `git pull` or `git checkout` leave nothing modified, so they don't either.
389
+ */
390
+ export function recentlyChangedFile(root, sinceSec) {
391
+ let out;
392
+ try {
393
+ out = execFileSync('git', ['-C', root, 'ls-files', '--modified', '--others', '--exclude-standard', '-z'], {
394
+ encoding: 'utf8',
395
+ timeout: GIT_TIMEOUT_MS,
396
+ maxBuffer: 8 * 1024 * 1024,
397
+ stdio: ['ignore', 'pipe', 'ignore'],
398
+ });
399
+ } catch {
400
+ return undefined; // not a git checkout, git missing, or too slow
401
+ }
402
+ let best;
403
+ let bestTime = sinceSec;
404
+ const names = out.split('\0');
405
+ for (let i = 0; i < names.length && i < MAX_CHANGED_FILES; i++) {
406
+ if (!names[i]) continue;
407
+ const abs = path.join(root, names[i]);
408
+ let mtime;
409
+ try {
410
+ mtime = fs.statSync(abs).mtimeMs / 1000;
411
+ } catch {
412
+ continue; // deleted
413
+ }
414
+ if (mtime > bestTime) {
415
+ best = abs;
416
+ bestTime = mtime;
417
+ }
418
+ }
419
+ return best;
420
+ }
421
+
366
422
  /** All file paths named in an apply_patch body ("*** Update File: path", Add/Delete/Move to). */
367
423
  export function parseApplyPatchPaths(text) {
368
424
  if (typeof text !== 'string') return [];
@@ -474,15 +530,22 @@ export function parsePayload(text) {
474
530
  }
475
531
  }
476
532
 
533
+ /** Working directory of a hook payload. Claude Code and Codex send `cwd`; Cursor sends `workspace_roots` (and sets CURSOR_PROJECT_DIR). */
534
+ export function payloadCwd(payload, env = process.env) {
535
+ const roots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots.filter((r) => typeof r === 'string' && r) : [];
536
+ return (typeof payload.cwd === 'string' && payload.cwd) || roots[0] || env.CURSOR_PROJECT_DIR || process.cwd();
537
+ }
538
+
477
539
  /**
478
540
  * Build exactly one heartbeat from a hook payload. Only derived, non-sensitive
479
541
  * fields are copied: never prompt text, tool output, file contents or paths.
542
+ * `changedFile` is a file a shell command was found to have written.
480
543
  */
481
- export function buildHeartbeat(payload, { agent, now = Date.now() / 1000, env = process.env, entityKey } = {}) {
482
- // Claude Code and Codex send `cwd`; Cursor sends `workspace_roots` (and sets CURSOR_PROJECT_DIR).
483
- const roots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots.filter((r) => typeof r === 'string' && r) : [];
484
- const cwd =
485
- (typeof payload.cwd === 'string' && payload.cwd) || roots[0] || env.CURSOR_PROJECT_DIR || process.cwd();
544
+ export function buildHeartbeat(
545
+ payload,
546
+ { agent, now = Date.now() / 1000, env = process.env, entityKey, sendBranch = true, changedFile } = {},
547
+ ) {
548
+ const cwd = payloadCwd(payload, env);
486
549
  const root = findGitRoot(cwd);
487
550
  const projectDir = root || path.resolve(cwd);
488
551
  const hb = {
@@ -490,14 +553,15 @@ export function buildHeartbeat(payload, { agent, now = Date.now() / 1000, env =
490
553
  project: path.basename(projectDir) || 'unknown',
491
554
  editor: agent,
492
555
  category: 'ai',
493
- is_write: isWriteTool(payload.tool_name) || payload.hook_event_name === 'afterFileEdit',
556
+ is_write: Boolean(changedFile) || isWriteTool(payload.tool_name) || payload.hook_event_name === 'afterFileEdit',
494
557
  };
495
- if (root) {
558
+ if (root && sendBranch) {
496
559
  const branch = readBranch(root);
497
560
  if (branch) hb.branch = branch;
498
561
  }
499
562
  // Cursor's afterFileEdit puts the edited file at the top level.
500
563
  const file =
564
+ changedFile ||
501
565
  extractFilePath(payload.tool_input, cwd) ||
502
566
  (typeof payload.file_path === 'string' && payload.file_path.trim() ? path.resolve(cwd, payload.file_path.trim()) : undefined);
503
567
  if (file) {
@@ -809,20 +873,39 @@ export async function runHook({ argv = [], env = process.env, stdinText = '' } =
809
873
  }
810
874
  const payload = parsePayload(stdinText);
811
875
  const event = typeof payload.hook_event_name === 'string' ? payload.hook_event_name : '';
812
- const hb = buildHeartbeat(payload, { agent, env });
813
- const { apiUrl, apiKey } = loadConfig(home, env);
876
+ const { apiUrl, apiKey, sendBranch } = loadConfig(home, env);
877
+ let hb = buildHeartbeat(payload, { agent, env, sendBranch });
878
+
879
+ // Every event records when this agent last acted in this project, so a shell
880
+ // command is only credited with files changed since the agent's previous step.
881
+ const state = readState(home);
882
+ const key = `${hb.editor}|${hb.project}`;
883
+ const previous = state.seen?.[key];
884
+ state.seen = { ...(state.seen || {}), [key]: hb.time };
885
+
886
+ if (!hb.is_write && event === 'PostToolUse' && isShellTool(payload.tool_name) && !shouldThrottle(state, hb, event)) {
887
+ const root = findGitRoot(payloadCwd(payload, env));
888
+ const since = typeof previous === 'number'
889
+ ? Math.max(previous, hb.time - SHELL_LOOKBACK_S)
890
+ : hb.time - SHELL_FIRST_LOOKBACK_S;
891
+ const changedFile = root && recentlyChangedFile(root, since);
892
+ if (changedFile) hb = buildHeartbeat(payload, { agent, env, sendBranch, changedFile, now: hb.time });
893
+ }
814
894
 
815
895
  // Only an agent changing a file counts as coding. Opening a session,
816
896
  // sending a prompt or reading files does not; those events just deliver
817
897
  // anything still queued.
818
898
  if (!hb.is_write) {
899
+ writeState(home, state);
819
900
  await flushQueue(home, { apiUrl, apiKey });
820
901
  return { status: 'not-writing' };
821
902
  }
822
903
 
823
- const state = readState(home);
824
- if (shouldThrottle(state, hb, event)) return { status: 'throttled' };
825
- state.last = { ...(state.last || {}), [`${hb.editor}|${hb.project}`]: hb.time };
904
+ if (shouldThrottle(state, hb, event)) {
905
+ writeState(home, state);
906
+ return { status: 'throttled' };
907
+ }
908
+ state.last = { ...(state.last || {}), [key]: hb.time };
826
909
  writeState(home, state);
827
910
 
828
911
  appendToQueue(home, hb);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "embertime",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Connect VS Code, Cursor, Windsurf, Claude Code and Codex to Ember in one command. Tracks coding time, never code or prompts.",
5
5
  "type": "module",
6
6
  "bin": {