embertime 0.1.3 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/cli.mjs +1 -0
- package/ember-hook.mjs +154 -20
- package/package.json +4 -2
- package/postinstall.mjs +20 -0
package/cli.mjs
CHANGED
|
@@ -193,6 +193,7 @@ function uninstall(o) {
|
|
|
193
193
|
const HELP = `embertime: track coding time across editors and AI agents
|
|
194
194
|
|
|
195
195
|
npx embertime setup --key <ember_key> [--api-url <url>] [--vsix <file>] [--skip-editors]
|
|
196
|
+
npx embertime setup re-run with the key you already saved
|
|
196
197
|
npx embertime status
|
|
197
198
|
npx embertime uninstall [--purge]
|
|
198
199
|
|
package/ember-hook.mjs
CHANGED
|
@@ -9,19 +9,22 @@
|
|
|
9
9
|
// * never writes to stdout for Claude Code / Codex (they may interpret it); for
|
|
10
10
|
// Cursor it prints the minimal JSON reply Cursor expects. Always exits 0
|
|
11
11
|
// * never sends file contents, raw paths or prompt text: files are reduced
|
|
12
|
-
// to
|
|
12
|
+
// to an HMAC of the absolute path, keyed with a random per-machine secret
|
|
13
|
+
// (~/.ember/entity.key), and the project to the git root's basename
|
|
13
14
|
// * heartbeats are appended to ~/.ember/queue.jsonl before any network I/O,
|
|
14
15
|
// so nothing is lost when the server is down
|
|
15
16
|
//
|
|
16
|
-
// 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).
|
|
17
19
|
|
|
18
20
|
import fs from 'node:fs';
|
|
19
21
|
import os from 'node:os';
|
|
20
22
|
import path from 'node:path';
|
|
21
23
|
import crypto from 'node:crypto';
|
|
24
|
+
import { execFileSync } from 'node:child_process';
|
|
22
25
|
import { fileURLToPath } from 'node:url';
|
|
23
26
|
|
|
24
|
-
export const VERSION = '0.1.
|
|
27
|
+
export const VERSION = '0.1.5';
|
|
25
28
|
export const AGENTS = ['claude-code', 'codex', 'cursor-agent'];
|
|
26
29
|
|
|
27
30
|
/** Events whose heartbeats are throttled: they fire on every tool call or file edit. */
|
|
@@ -59,6 +62,7 @@ function paths(home) {
|
|
|
59
62
|
state: path.join(home, 'hook-state.json'),
|
|
60
63
|
log: path.join(home, 'hook.log'),
|
|
61
64
|
lastError: path.join(home, 'last_error'),
|
|
65
|
+
entityKey: path.join(home, 'entity.key'),
|
|
62
66
|
};
|
|
63
67
|
}
|
|
64
68
|
|
|
@@ -70,12 +74,16 @@ function readJson(file, fallback) {
|
|
|
70
74
|
}
|
|
71
75
|
}
|
|
72
76
|
|
|
73
|
-
/**
|
|
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
|
+
*/
|
|
74
81
|
export function loadConfig(home, env = process.env) {
|
|
75
82
|
const cfg = readJson(paths(home).config, {}) || {};
|
|
76
83
|
const apiUrl = String(env.EMBER_API_URL || cfg.api_url || DEFAULT_API_URL).replace(/\/+$/, '');
|
|
77
84
|
const apiKey = env.EMBER_API_KEY || cfg.api_key || '';
|
|
78
|
-
|
|
85
|
+
const sendBranch = env.EMBER_SEND_BRANCH !== undefined ? env.EMBER_SEND_BRANCH !== '0' : cfg.send_branch !== false;
|
|
86
|
+
return { apiUrl, apiKey, sendBranch };
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
function writeAtomic(file, data) {
|
|
@@ -361,6 +369,56 @@ export function isWriteTool(toolName) {
|
|
|
361
369
|
return typeof toolName === 'string' && WRITE_TOOLS.has(toolName.toLowerCase());
|
|
362
370
|
}
|
|
363
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
|
+
|
|
364
422
|
/** All file paths named in an apply_patch body ("*** Update File: path", Add/Delete/Move to). */
|
|
365
423
|
export function parseApplyPatchPaths(text) {
|
|
366
424
|
if (typeof text !== 'string') return [];
|
|
@@ -409,8 +467,57 @@ export function extractFilePath(toolInput, cwd) {
|
|
|
409
467
|
return path.resolve(cwd || process.cwd(), p);
|
|
410
468
|
}
|
|
411
469
|
|
|
412
|
-
|
|
413
|
-
|
|
470
|
+
/**
|
|
471
|
+
* File identity sent as `entity`: HMAC-SHA256 of the absolute path under the
|
|
472
|
+
* per-machine key, cut to the 40 hex characters the server accepts. A plain hash
|
|
473
|
+
* of a path could be reversed by guessing likely paths; without the key it can't.
|
|
474
|
+
*/
|
|
475
|
+
export function entityHash(key, file) {
|
|
476
|
+
return crypto.createHmac('sha256', key).update(file).digest('hex').slice(0, 40);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const ENTITY_KEY_RE = /^[a-f0-9]{64}$/;
|
|
480
|
+
|
|
481
|
+
function readEntityKey(file) {
|
|
482
|
+
try {
|
|
483
|
+
const key = fs.readFileSync(file, 'utf8').trim();
|
|
484
|
+
return ENTITY_KEY_RE.test(key) ? key : undefined;
|
|
485
|
+
} catch {
|
|
486
|
+
return undefined;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* The random key shared by every collector on this machine (mode 0600), created
|
|
492
|
+
* on first use. Concurrent first runs agree on one key: the first hard link wins.
|
|
493
|
+
* If it can't be stored, a one-off key is used: entities then differ between runs,
|
|
494
|
+
* which only weakens de-duplication, never privacy.
|
|
495
|
+
*/
|
|
496
|
+
export function loadEntityKey(home) {
|
|
497
|
+
const file = paths(home).entityKey;
|
|
498
|
+
const existing = readEntityKey(file);
|
|
499
|
+
if (existing) return existing;
|
|
500
|
+
const key = crypto.randomBytes(32).toString('hex');
|
|
501
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
502
|
+
try {
|
|
503
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
504
|
+
fs.writeFileSync(tmp, `${key}\n`, { mode: 0o600 });
|
|
505
|
+
try {
|
|
506
|
+
fs.linkSync(tmp, file);
|
|
507
|
+
return key;
|
|
508
|
+
} catch (e) {
|
|
509
|
+
if (e.code !== 'EEXIST') throw e;
|
|
510
|
+
const winner = readEntityKey(file);
|
|
511
|
+
if (winner) return winner;
|
|
512
|
+
fs.renameSync(tmp, file); // existing file is corrupt: replace it
|
|
513
|
+
return key;
|
|
514
|
+
}
|
|
515
|
+
} catch (e) {
|
|
516
|
+
log(home, 'warn', `could not store entity key: ${e.message}`);
|
|
517
|
+
return key;
|
|
518
|
+
} finally {
|
|
519
|
+
fs.rmSync(tmp, { force: true });
|
|
520
|
+
}
|
|
414
521
|
}
|
|
415
522
|
|
|
416
523
|
export function parsePayload(text) {
|
|
@@ -423,15 +530,22 @@ export function parsePayload(text) {
|
|
|
423
530
|
}
|
|
424
531
|
}
|
|
425
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
|
+
|
|
426
539
|
/**
|
|
427
540
|
* Build exactly one heartbeat from a hook payload. Only derived, non-sensitive
|
|
428
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.
|
|
429
543
|
*/
|
|
430
|
-
export function buildHeartbeat(
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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);
|
|
435
549
|
const root = findGitRoot(cwd);
|
|
436
550
|
const projectDir = root || path.resolve(cwd);
|
|
437
551
|
const hb = {
|
|
@@ -439,18 +553,19 @@ export function buildHeartbeat(payload, { agent, now = Date.now() / 1000, env =
|
|
|
439
553
|
project: path.basename(projectDir) || 'unknown',
|
|
440
554
|
editor: agent,
|
|
441
555
|
category: 'ai',
|
|
442
|
-
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',
|
|
443
557
|
};
|
|
444
|
-
if (root) {
|
|
558
|
+
if (root && sendBranch) {
|
|
445
559
|
const branch = readBranch(root);
|
|
446
560
|
if (branch) hb.branch = branch;
|
|
447
561
|
}
|
|
448
562
|
// Cursor's afterFileEdit puts the edited file at the top level.
|
|
449
563
|
const file =
|
|
564
|
+
changedFile ||
|
|
450
565
|
extractFilePath(payload.tool_input, cwd) ||
|
|
451
566
|
(typeof payload.file_path === 'string' && payload.file_path.trim() ? path.resolve(cwd, payload.file_path.trim()) : undefined);
|
|
452
567
|
if (file) {
|
|
453
|
-
hb.entity =
|
|
568
|
+
hb.entity = entityHash(entityKey ?? loadEntityKey(emberHome(env)), file);
|
|
454
569
|
const language = languageForPath(file);
|
|
455
570
|
if (language) hb.language = language;
|
|
456
571
|
}
|
|
@@ -758,20 +873,39 @@ export async function runHook({ argv = [], env = process.env, stdinText = '' } =
|
|
|
758
873
|
}
|
|
759
874
|
const payload = parsePayload(stdinText);
|
|
760
875
|
const event = typeof payload.hook_event_name === 'string' ? payload.hook_event_name : '';
|
|
761
|
-
const
|
|
762
|
-
|
|
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
|
+
}
|
|
763
894
|
|
|
764
895
|
// Only an agent changing a file counts as coding. Opening a session,
|
|
765
896
|
// sending a prompt or reading files does not; those events just deliver
|
|
766
897
|
// anything still queued.
|
|
767
898
|
if (!hb.is_write) {
|
|
899
|
+
writeState(home, state);
|
|
768
900
|
await flushQueue(home, { apiUrl, apiKey });
|
|
769
901
|
return { status: 'not-writing' };
|
|
770
902
|
}
|
|
771
903
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
904
|
+
if (shouldThrottle(state, hb, event)) {
|
|
905
|
+
writeState(home, state);
|
|
906
|
+
return { status: 'throttled' };
|
|
907
|
+
}
|
|
908
|
+
state.last = { ...(state.last || {}), [key]: hb.time };
|
|
775
909
|
writeState(home, state);
|
|
776
910
|
|
|
777
911
|
appendToQueue(home, hb);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "embertime",
|
|
3
|
-
"version": "0.1.
|
|
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": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"cli.mjs",
|
|
11
11
|
"editors.mjs",
|
|
12
|
+
"postinstall.mjs",
|
|
12
13
|
"ember-hook.mjs",
|
|
13
14
|
"install.mjs",
|
|
14
15
|
"hooks",
|
|
@@ -30,7 +31,8 @@
|
|
|
30
31
|
"heatmap"
|
|
31
32
|
],
|
|
32
33
|
"scripts": {
|
|
33
|
-
"test": "node --test test/*.test.mjs"
|
|
34
|
+
"test": "node --test test/*.test.mjs",
|
|
35
|
+
"postinstall": "node postinstall.mjs"
|
|
34
36
|
},
|
|
35
37
|
"repository": {
|
|
36
38
|
"type": "git",
|
package/postinstall.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Runs after `npm install -g embertime` (and upgrades). If Ember was already set
|
|
2
|
+
// up on this machine, refresh the hook copy in ~/.ember/agent-hook so agents run
|
|
3
|
+
// the new version straight away. Your key in ~/.ember/config.json is untouched.
|
|
4
|
+
// Best effort: it never fails the install and does nothing on a fresh machine.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const dest = path.join(process.env.EMBER_HOME || path.join(os.homedir(), '.ember'), 'agent-hook');
|
|
14
|
+
if (fs.existsSync(path.join(dest, 'ember-hook.mjs'))) {
|
|
15
|
+
for (const f of ['ember-hook.mjs', 'install.mjs', 'package.json']) fs.copyFileSync(path.join(here, f), path.join(dest, f));
|
|
16
|
+
console.log('embertime: updated the hook in ~/.ember/agent-hook (your key is unchanged).');
|
|
17
|
+
}
|
|
18
|
+
} catch {
|
|
19
|
+
/* never block an install */
|
|
20
|
+
}
|