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.
- package/ember-hook.mjs +99 -16
- 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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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(
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
|
813
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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