agent-hours 0.3.0

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/dist/core.js ADDED
@@ -0,0 +1,417 @@
1
+ /**
2
+ * claude-hours core — ported 1:1 from the battle-tested Python reference
3
+ * (reference/claude_hours.py + reference/prototype-split.py).
4
+ *
5
+ * Parity is enforced by test/parity.test.mjs: same fixtures through both
6
+ * implementations must yield identical numbers.
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import * as os from "node:os";
11
+ export const PROJECTS_BASE = path.join(os.homedir(), ".claude", "projects");
12
+ /** /Users/x/code/foo -> -Users-x-code-foo (Claude Code's project dir naming). */
13
+ export function projectToHash(p) {
14
+ return path.resolve(p).split(path.sep).join("-");
15
+ }
16
+ export function findProjectDir(projectArg) {
17
+ let hashName;
18
+ if (projectArg) {
19
+ hashName =
20
+ projectArg.includes(path.sep) || projectArg.startsWith("/")
21
+ ? projectToHash(projectArg)
22
+ : projectArg;
23
+ }
24
+ else {
25
+ hashName = projectToHash(process.cwd());
26
+ }
27
+ return path.join(PROJECTS_BASE, hashName);
28
+ }
29
+ /**
30
+ * Event classification, verified against real Claude Code logs (2026-06):
31
+ *
32
+ * - `promptSource` (newer logs): "typed" = human, "system" = scheduled
33
+ * tasks/hooks (phantom prompts!), "queued" = injection moment of a queued
34
+ * message — the human typing already happened at the queue-operation
35
+ * enqueue event, so the injection itself is NOT human time.
36
+ * - `queue-operation` enqueue with human content = the moment of real typing
37
+ * WHILE Claude was running (strong presence proof). Enqueues carrying
38
+ * `<task-notification>` payloads are system traffic.
39
+ * - `isSidechain` = subagent transcript — machine, never a human prompt.
40
+ * - `isCompactSummary` = synthetic continuation prompt, not human.
41
+ * - `attachment` edited_text_file = user edited a file in an external editor
42
+ * (presence proof, not a prompt).
43
+ * - Legacy logs without `promptSource` fall back to the shape heuristic:
44
+ * type=user, not isMeta, content is a string or has text but no tool_result.
45
+ */
46
+ export function classifyRecord(record) {
47
+ const work = { kind: "work", presence: false };
48
+ const r = record;
49
+ if (!r)
50
+ return work;
51
+ if (r["isSidechain"] === true)
52
+ return work;
53
+ const t = r["type"];
54
+ if (t === "queue-operation") {
55
+ const content = r["content"];
56
+ if (r["operation"] === "enqueue" &&
57
+ typeof content === "string" &&
58
+ !content.trimStart().startsWith("<task-notification")) {
59
+ return { kind: "prompt", presence: true };
60
+ }
61
+ return work;
62
+ }
63
+ if (t === "attachment") {
64
+ const a = r["attachment"];
65
+ if (a?.["type"] === "edited_text_file")
66
+ return { kind: "work", presence: true };
67
+ return work;
68
+ }
69
+ if (t === "user" && !r["isMeta"] && !r["isCompactSummary"]) {
70
+ const src = r["promptSource"];
71
+ if (src === "system" || src === "queued")
72
+ return work;
73
+ if (src === "typed")
74
+ return { kind: "prompt", presence: true };
75
+ const msg = r["message"];
76
+ const c = msg?.["content"];
77
+ if (typeof c === "string")
78
+ return { kind: "prompt", presence: true };
79
+ if (Array.isArray(c)) {
80
+ const items = c.filter((i) => i !== null && typeof i === "object");
81
+ const hasText = items.some((i) => i["type"] === "text");
82
+ const hasToolResult = items.some((i) => i["type"] === "tool_result");
83
+ if (hasText && !hasToolResult)
84
+ return { kind: "prompt", presence: true };
85
+ }
86
+ }
87
+ return work;
88
+ }
89
+ /** Convenience wrapper: just the kind. */
90
+ export function classifyKind(record) {
91
+ return classifyRecord(record).kind;
92
+ }
93
+ /**
94
+ * Reads all events of one session JSONL, filtered to [sinceMs, untilMs].
95
+ * With forceWork (subagent transcripts) every event counts as machine work —
96
+ * subagent "user" messages are task prompts from the orchestrator, not humans.
97
+ */
98
+ export function loadSessionEvents(jsonlPath, sinceMs, untilMs, forceWork = false) {
99
+ let raw;
100
+ try {
101
+ raw = fs.readFileSync(jsonlPath, "utf8");
102
+ }
103
+ catch {
104
+ return [];
105
+ }
106
+ const events = [];
107
+ for (const line of raw.split("\n")) {
108
+ if (!line.trim())
109
+ continue;
110
+ let record;
111
+ try {
112
+ record = JSON.parse(line);
113
+ }
114
+ catch {
115
+ continue;
116
+ }
117
+ const tsStr = record?.["timestamp"];
118
+ if (typeof tsStr !== "string")
119
+ continue;
120
+ const ts = Date.parse(tsStr);
121
+ if (Number.isNaN(ts))
122
+ continue;
123
+ if (ts < sinceMs || ts > untilMs)
124
+ continue;
125
+ if (forceWork) {
126
+ events.push({ ts, kind: "work", presence: false });
127
+ }
128
+ else {
129
+ const c = classifyRecord(record);
130
+ events.push({ ts, kind: c.kind, presence: c.presence });
131
+ }
132
+ }
133
+ events.sort((a, b) => a.ts - b.ts);
134
+ return events;
135
+ }
136
+ /**
137
+ * Loads all sessions of a project directory: top-level *.jsonl PLUS subagent
138
+ * transcripts in <sessionUuid>/subagents/agent-*.jsonl (newer Claude Code
139
+ * versions store parallel-agent work there — missing them undercounts total
140
+ * activity whenever only subagents were running).
141
+ */
142
+ export function loadProject(projectDir, sinceMs, untilMs) {
143
+ let entries;
144
+ try {
145
+ entries = fs.readdirSync(projectDir, { withFileTypes: true });
146
+ }
147
+ catch {
148
+ return [];
149
+ }
150
+ const sessions = [];
151
+ for (const f of entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => e.name).sort()) {
152
+ const events = loadSessionEvents(path.join(projectDir, f), sinceMs, untilMs);
153
+ if (events.length > 0)
154
+ sessions.push({ name: f, events });
155
+ }
156
+ for (const dir of entries.filter((e) => e.isDirectory()).map((e) => e.name).sort()) {
157
+ const subDir = path.join(projectDir, dir, "subagents");
158
+ let subFiles;
159
+ try {
160
+ subFiles = fs.readdirSync(subDir).filter((f) => f.endsWith(".jsonl")).sort();
161
+ }
162
+ catch {
163
+ continue;
164
+ }
165
+ for (const f of subFiles) {
166
+ const events = loadSessionEvents(path.join(subDir, f), sinceMs, untilMs, true);
167
+ if (events.length > 0)
168
+ sessions.push({ name: `${dir}/subagents/${f}`, events });
169
+ }
170
+ }
171
+ return sessions;
172
+ }
173
+ /**
174
+ * Industry-standard active time (WakaTime/RescueTime style): sum of
175
+ * inter-event gaps, each gap capped at capMinutes ("cap bonus" — a pause
176
+ * longer than the cap still contributes capMinutes).
177
+ */
178
+ export function activeMinutes(timesMs, capMinutes) {
179
+ if (timesMs.length < 2)
180
+ return 0;
181
+ let total = 0;
182
+ for (let i = 1; i < timesMs.length; i++) {
183
+ const gap = (timesMs[i] - timesMs[i - 1]) / 60000;
184
+ total += Math.min(gap, capMinutes);
185
+ }
186
+ return total;
187
+ }
188
+ /** Strict variant: gaps > cap count 0 (no cap bonus). Honest lower bound. */
189
+ export function activeMinutesStrict(timesMs, capMinutes) {
190
+ if (timesMs.length < 2)
191
+ return 0;
192
+ let total = 0;
193
+ for (let i = 1; i < timesMs.length; i++) {
194
+ const gap = (timesMs[i] - timesMs[i - 1]) / 60000;
195
+ if (gap <= capMinutes)
196
+ total += gap;
197
+ }
198
+ return total;
199
+ }
200
+ /** All gaps >= minMinutes, sorted by duration descending. */
201
+ export function findPauses(timesMs, minMinutes) {
202
+ const pauses = [];
203
+ for (let i = 1; i < timesMs.length; i++) {
204
+ const minutes = (timesMs[i] - timesMs[i - 1]) / 60000;
205
+ if (minutes >= minMinutes) {
206
+ pauses.push({ startMs: timesMs[i - 1], endMs: timesMs[i], minutes });
207
+ }
208
+ }
209
+ pauses.sort((a, b) => b.minutes - a.minutes);
210
+ return pauses;
211
+ }
212
+ /**
213
+ * Merge all sessions into ONE sorted timeline. Critical for parallel
214
+ * sessions/agents: per-session sums would double-count overlapping wall-clock
215
+ * time; the merged timeline counts every real minute exactly once.
216
+ */
217
+ export function mergeEvents(sessions) {
218
+ const merged = [];
219
+ for (const s of sessions)
220
+ merged.push(...s.events);
221
+ merged.sort((a, b) => a.ts - b.ts);
222
+ return merged;
223
+ }
224
+ /** True if at least two sessions' time windows overlap. */
225
+ export function detectOverlaps(sessions) {
226
+ const ranges = sessions
227
+ .filter((s) => s.events.length >= 2)
228
+ .map((s) => [s.events[0].ts, s.events[s.events.length - 1].ts])
229
+ .sort((a, b) => a[0] - b[0] || a[1] - b[1]);
230
+ for (let i = 1; i < ranges.length; i++) {
231
+ if (ranges[i][0] < ranges[i - 1][1])
232
+ return true;
233
+ }
234
+ return false;
235
+ }
236
+ /**
237
+ * The core USP. Total = capped inter-event time over the merged timeline of
238
+ * ALL events. Human-active = capped inter-PROMPT time (its own cap — "waiting
239
+ * for Claude and testing" counts as active). AI-solo = total − human.
240
+ */
241
+ export function computeSplit(merged, capMinutes, promptCapMinutes) {
242
+ const allTimes = merged.map((e) => e.ts);
243
+ const promptTimes = merged.filter((e) => e.kind === "prompt").map((e) => e.ts);
244
+ const totalMinutes = activeMinutes(allTimes, capMinutes);
245
+ const humanMinutes = activeMinutes(promptTimes, promptCapMinutes);
246
+ return {
247
+ totalMinutes,
248
+ humanMinutes: Math.min(humanMinutes, totalMinutes),
249
+ aiSoloMinutes: Math.max(0, totalMinutes - humanMinutes),
250
+ promptCount: promptTimes.length,
251
+ };
252
+ }
253
+ /** Local-date key (YYYY-MM-DD) for a UTC timestamp at the given offset. */
254
+ export function dayKey(tsMs, tzOffsetHours) {
255
+ return new Date(tsMs + tzOffsetHours * 3600_000).toISOString().slice(0, 10);
256
+ }
257
+ /** Local-hour key (YYYY-MM-DD HH:00) for a UTC timestamp at the given offset. */
258
+ export function hourKey(tsMs, tzOffsetHours) {
259
+ return (new Date(tsMs + tzOffsetHours * 3600_000).toISOString().slice(0, 13).replace("T", " ") +
260
+ ":00");
261
+ }
262
+ /**
263
+ * Three-state model (replaces the binary inter-prompt heuristic, which counts
264
+ * Claude-working time inside prompt windows as fully human):
265
+ *
266
+ * Per prompt-to-prompt window, capped at promptCapMinutes (same cap semantics
267
+ * as the old heuristic, so the old number stays a true upper bound):
268
+ *
269
+ * 1. hands-on — the reaction tail: time between the last event of any kind
270
+ * and the next prompt. The human provably read/thought/typed then.
271
+ * 2. supervised — the agent-working part of the capped window, weighted by
272
+ * watch evidence: reaction < 30 s ⇒ they were watching ⇒ 100 %, < 5 min ⇒
273
+ * 50 %, else 0 %. Hard presence proof inside the window (message typed
274
+ * mid-turn, external file edit) forces 100 %.
275
+ * 3. AI autonomous — total minus the two above.
276
+ *
277
+ * Invariant: handsOn + supervised <= upperBound (old heuristic) <= total.
278
+ * A fast reaction proves presence at the END of a window, not throughout —
279
+ * capping supervision at the prompt-cap window encodes exactly that.
280
+ */
281
+ export function computeRefinedSplit(merged, opts) {
282
+ const WATCH_FULL = opts.watchFullMinutes ?? 0.5;
283
+ const WATCH_HALF = opts.watchHalfMinutes ?? 5;
284
+ const n = merged.length;
285
+ const promptIdx = [];
286
+ for (let i = 0; i < n; i++)
287
+ if (merged[i].kind === "prompt")
288
+ promptIdx.push(i);
289
+ // Pass 1: totals per hour over the full event timeline.
290
+ let total = 0;
291
+ const byHour = new Map();
292
+ const hourOf = (tsMs) => hourKey(tsMs, opts.tzOffsetHours);
293
+ const bucket = (key) => {
294
+ let b = byHour.get(key);
295
+ if (!b) {
296
+ b = { total: 0, handsOn: 0, supervised: 0, ai: 0 };
297
+ byHour.set(key, b);
298
+ }
299
+ return b;
300
+ };
301
+ for (let i = 1; i < n; i++) {
302
+ const capped = Math.min((merged[i].ts - merged[i - 1].ts) / 60000, opts.capMinutes);
303
+ total += capped;
304
+ bucket(hourOf(merged[i - 1].ts)).total += capped;
305
+ }
306
+ // Pass 2: human states per prompt-to-prompt segment.
307
+ let handsOn = 0;
308
+ let supervised = 0;
309
+ for (let k = 1; k < promptIdx.length; k++) {
310
+ const p1 = promptIdx[k - 1];
311
+ const p2 = promptIdx[k];
312
+ const windowMin = (merged[p2].ts - merged[p1].ts) / 60000;
313
+ const cappedWindow = Math.min(windowMin, opts.promptCapMinutes);
314
+ const reactionMin = (merged[p2].ts - merged[p2 - 1].ts) / 60000;
315
+ const tail = Math.min(reactionMin, cappedWindow);
316
+ const agentPart = cappedWindow - tail;
317
+ let proof = false;
318
+ for (let j = p2 - 1; j > p1; j--) {
319
+ if (merged[j].presence) {
320
+ proof = true;
321
+ break;
322
+ }
323
+ }
324
+ let w = 0;
325
+ if (proof || reactionMin <= WATCH_FULL)
326
+ w = 1;
327
+ else if (reactionMin <= WATCH_HALF)
328
+ w = 0.5;
329
+ const sup = agentPart * w;
330
+ handsOn += tail;
331
+ supervised += sup;
332
+ // Hour attribution: tail to the hour of the tail gap's start; supervised
333
+ // distributed over the segment's work gaps proportionally to capped time.
334
+ bucket(hourOf(merged[p2 - 1].ts)).handsOn += tail;
335
+ if (sup > 0) {
336
+ let denom = 0;
337
+ for (let i = p1 + 1; i < p2; i++) {
338
+ denom += Math.min((merged[i].ts - merged[i - 1].ts) / 60000, opts.capMinutes);
339
+ }
340
+ if (denom > 0) {
341
+ for (let i = p1 + 1; i < p2; i++) {
342
+ const g = Math.min((merged[i].ts - merged[i - 1].ts) / 60000, opts.capMinutes);
343
+ bucket(hourOf(merged[i - 1].ts)).supervised += (sup * g) / denom;
344
+ }
345
+ }
346
+ else {
347
+ bucket(hourOf(merged[p1].ts)).supervised += sup;
348
+ }
349
+ }
350
+ }
351
+ for (const b of byHour.values()) {
352
+ b.ai = Math.max(0, b.total - b.handsOn - b.supervised);
353
+ }
354
+ const promptTimes = promptIdx.map((i) => merged[i].ts);
355
+ const upperBound = Math.min(activeMinutes(promptTimes, opts.promptCapMinutes), total);
356
+ const attention = handsOn + supervised;
357
+ return {
358
+ totalMinutes: total,
359
+ handsOnMinutes: handsOn,
360
+ supervisedMinutes: supervised,
361
+ attentionMinutes: attention,
362
+ upperBoundMinutes: upperBound,
363
+ aiAutonomousMinutes: total - attention,
364
+ promptCount: promptIdx.length,
365
+ byHour,
366
+ };
367
+ }
368
+ /**
369
+ * Per-day capped minutes. Each gap is attributed to the day of its EARLIER
370
+ * event (same convention as the Python reference's --by-day and --csv).
371
+ */
372
+ export function bucketMinutesByDay(timesMs, capMinutes, tzOffsetHours) {
373
+ const days = new Map();
374
+ for (let i = 1; i < timesMs.length; i++) {
375
+ const gap = Math.min((timesMs[i] - timesMs[i - 1]) / 60000, capMinutes);
376
+ const day = dayKey(timesMs[i - 1], tzOffsetHours);
377
+ days.set(day, (days.get(day) ?? 0) + gap);
378
+ }
379
+ return days;
380
+ }
381
+ export function countByDay(timesMs, tzOffsetHours) {
382
+ const days = new Map();
383
+ for (const t of timesMs) {
384
+ const day = dayKey(t, tzOffsetHours);
385
+ days.set(day, (days.get(day) ?? 0) + 1);
386
+ }
387
+ return days;
388
+ }
389
+ /**
390
+ * Parses YYYY-MM-DD, "YYYY-MM-DD HH:MM[:SS]" or an ISO timestamp — all
391
+ * interpreted as UTC (same contract as the Python reference).
392
+ */
393
+ export function parseDate(s) {
394
+ if (s.includes("T")) {
395
+ const ts = Date.parse(s);
396
+ if (Number.isNaN(ts))
397
+ throw new Error(`Cannot parse date '${s}'`);
398
+ return ts;
399
+ }
400
+ let iso;
401
+ if (s.includes(" ")) {
402
+ const m = s.match(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})(:\d{2})?$/);
403
+ if (!m)
404
+ throw new Error(`Cannot parse date '${s}' (expected YYYY-MM-DD or YYYY-MM-DD HH:MM)`);
405
+ iso = `${m[1]}T${m[2]}${m[3] ?? ":00"}Z`;
406
+ }
407
+ else {
408
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) {
409
+ throw new Error(`Cannot parse date '${s}' (expected YYYY-MM-DD)`);
410
+ }
411
+ iso = `${s}T00:00:00Z`;
412
+ }
413
+ const ts = Date.parse(iso);
414
+ if (Number.isNaN(ts))
415
+ throw new Error(`Cannot parse date '${s}'`);
416
+ return ts;
417
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `agent-hours install` — headroom-style one-shot integration.
3
+ *
4
+ * Drops a SKILL.md into the agents' skill directories so users can simply
5
+ * ask their agent "what did I work on this week?" and it knows to run this
6
+ * CLI and interpret the numbers. Idempotent; overwrites our own skill file
7
+ * only.
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import * as os from "node:os";
12
+ export const SKILL_MD = `---
13
+ name: agent-hours
14
+ description: Billable hours and worklog from coding-agent session logs (Claude Code + Codex, merged). Use when the user asks "how many hours", "what did I work on", "time tracking", "billing", "invoice summary", "Stunden", "woran habe ich gearbeitet", "Arbeitszeit", "Abrechnung", or wants a per-hour/per-day work summary for a period.
15
+ ---
16
+
17
+ # agent-hours
18
+
19
+ Answer time-tracking and what-was-done questions by running the agent-hours
20
+ CLI (reads local session logs retroactively — zero setup) and interpreting
21
+ its output. Run it from the project directory the user asks about.
22
+
23
+ ## Commands
24
+
25
+ \`\`\`bash
26
+ npx agent-hours --json # full three-state split, machine-readable
27
+ npx agent-hours --worklog --csv --by-day # per-day: hours per state + description
28
+ npx agent-hours --worklog --csv # per-hour variant
29
+ npx agent-hours --json --since 2026-06-01 --until 2026-06-30
30
+ npx agent-hours --all-projects --split # everything on this machine
31
+ \`\`\`
32
+
33
+ ## How to answer
34
+
35
+ 1. **"How many hours did I work?"** — run \`--json\`. Present the BAND, never a
36
+ single number as truth: hands-on hours (provable lower bound), attention
37
+ hours (model estimate incl. supervised agent time), upper bound. Mention
38
+ total agent runtime separately.
39
+ 2. **"What did I work on?"** — run \`--worklog --csv --by-day\` (or per-hour for
40
+ one day) and summarize the description column in your own words, grouped
41
+ by theme. This is free — do NOT pass --summarize unless the user asks.
42
+ 3. **Invoice/billing export** — write the CSV to ~/Downloads and remind the
43
+ user: browser/call time is not in the logs, add 15–25 % on top.
44
+
45
+ ## Notes
46
+
47
+ - Sources are merged into ONE timeline (Claude Code + Codex; parallel agents
48
+ and agent-launched agents never double-count).
49
+ - Claude Code prunes logs after cleanupPeriodDays (default 30) — if a range
50
+ looks empty, say so and recommend raising it in ~/.claude/settings.json.
51
+ - Idle-cap methodology: gaps between events capped at 10 min (configurable
52
+ via --cap/--prompt-cap).
53
+ `;
54
+ function installSkillFile(skillDir) {
55
+ const target = path.join(skillDir, "agent-hours", "SKILL.md");
56
+ const existed = fs.existsSync(target);
57
+ fs.mkdirSync(path.dirname(target), { recursive: true });
58
+ fs.writeFileSync(target, SKILL_MD);
59
+ return { target: skillDir, path: target, status: existed ? "updated" : "installed" };
60
+ }
61
+ /**
62
+ * Installs the skill for the requested agents. `homeDir` is overridable for
63
+ * tests. Codex is skipped (not failed) when ~/.codex doesn't exist.
64
+ */
65
+ export function runInstall(agent, homeDir = os.homedir()) {
66
+ const results = [];
67
+ if (agent === "claude" || agent === "all") {
68
+ results.push(installSkillFile(path.join(homeDir, ".claude", "skills")));
69
+ }
70
+ if (agent === "codex" || agent === "all") {
71
+ if (fs.existsSync(path.join(homeDir, ".codex"))) {
72
+ results.push(installSkillFile(path.join(homeDir, ".codex", "skills")));
73
+ }
74
+ else {
75
+ results.push({
76
+ target: path.join(homeDir, ".codex"),
77
+ path: "",
78
+ status: "skipped",
79
+ reason: "~/.codex not found (Codex CLI not installed)",
80
+ });
81
+ }
82
+ }
83
+ return results;
84
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Codex CLI source adapter.
3
+ *
4
+ * Codex stores sessions in ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl.
5
+ * Every line has a top-level ISO `timestamp`; the first line is a
6
+ * `session_meta` record carrying `payload.cwd` (project matching) and
7
+ * `payload.source` ("exec" = non-interactive run launched by a script or
8
+ * another agent — its "user" messages are machine-authored, so the whole
9
+ * session counts as AI runtime; "tui"/IDE sessions have real human prompts).
10
+ *
11
+ * Verified against real logs 2026-06-12 (Codex CLI 0.135/0.139).
12
+ */
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import * as os from "node:os";
16
+ export const CODEX_SESSIONS_BASE = path.join(os.homedir(), ".codex", "sessions");
17
+ function walkJsonl(dir, out = []) {
18
+ let entries;
19
+ try {
20
+ entries = fs.readdirSync(dir, { withFileTypes: true });
21
+ }
22
+ catch {
23
+ return out;
24
+ }
25
+ for (const e of entries) {
26
+ const p = path.join(dir, e.name);
27
+ if (e.isDirectory())
28
+ walkJsonl(p, out);
29
+ else if (e.isFile() && e.name.endsWith(".jsonl"))
30
+ out.push(p);
31
+ }
32
+ return out;
33
+ }
34
+ /**
35
+ * Cheap header check: only the first line is needed to match the project.
36
+ * The session_meta line can be huge (it embeds the agent's base instructions),
37
+ * so read in growing chunks until the first newline appears.
38
+ */
39
+ function readMeta(file) {
40
+ let fd;
41
+ try {
42
+ fd = fs.openSync(file, "r");
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ try {
48
+ const CHUNK = 65536;
49
+ const MAX = 4 * 1024 * 1024;
50
+ let data = Buffer.alloc(0);
51
+ let pos = 0;
52
+ let nl = -1;
53
+ while (nl < 0 && pos < MAX) {
54
+ const buf = Buffer.alloc(CHUNK);
55
+ const n = fs.readSync(fd, buf, 0, CHUNK, pos);
56
+ if (n <= 0)
57
+ break;
58
+ data = Buffer.concat([data, buf.subarray(0, n)]);
59
+ pos += n;
60
+ nl = data.indexOf(0x0a);
61
+ }
62
+ const firstLine = (nl >= 0 ? data.subarray(0, nl) : data).toString("utf8");
63
+ const rec = JSON.parse(firstLine);
64
+ if (rec["type"] !== "session_meta")
65
+ return null;
66
+ const payload = (rec["payload"] ?? {});
67
+ return {
68
+ cwd: typeof payload["cwd"] === "string" ? payload["cwd"] : null,
69
+ interactive: payload["source"] !== "exec",
70
+ };
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ finally {
76
+ fs.closeSync(fd);
77
+ }
78
+ }
79
+ function parseEvents(file, sinceMs, untilMs, interactive) {
80
+ let raw;
81
+ try {
82
+ raw = fs.readFileSync(file, "utf8");
83
+ }
84
+ catch {
85
+ return [];
86
+ }
87
+ const events = [];
88
+ for (const line of raw.split("\n")) {
89
+ if (!line.trim())
90
+ continue;
91
+ let r;
92
+ try {
93
+ r = JSON.parse(line);
94
+ }
95
+ catch {
96
+ continue;
97
+ }
98
+ const tsStr = r["timestamp"];
99
+ if (typeof tsStr !== "string")
100
+ continue;
101
+ const ts = Date.parse(tsStr);
102
+ if (Number.isNaN(ts) || ts < sinceMs || ts > untilMs)
103
+ continue;
104
+ let kind = "work";
105
+ let presence = false;
106
+ if (interactive && r["type"] === "response_item") {
107
+ const p = (r["payload"] ?? {});
108
+ if (p["type"] === "message" && p["role"] === "user") {
109
+ const content = p["content"];
110
+ const text = Array.isArray(content)
111
+ ? content.find((c) => c && typeof c === "object" && c["type"] === "input_text")?.["text"]
112
+ : undefined;
113
+ // Codex injects environment/permission context as user-role messages
114
+ // wrapped in <tags> — those are not human.
115
+ if (typeof text === "string" && !text.trimStart().startsWith("<")) {
116
+ kind = "prompt";
117
+ presence = true;
118
+ }
119
+ }
120
+ }
121
+ events.push({ ts, kind, presence });
122
+ }
123
+ events.sort((a, b) => a.ts - b.ts);
124
+ return events;
125
+ }
126
+ /**
127
+ * Loads all Codex sessions whose cwd is the project path (or inside it).
128
+ * Session names are prefixed "codex:" for --by-session readability.
129
+ */
130
+ export function loadCodexSessions(projectPath, sinceMs, untilMs, baseDir = CODEX_SESSIONS_BASE) {
131
+ const project = path.resolve(projectPath);
132
+ const sessions = [];
133
+ for (const file of walkJsonl(baseDir).sort()) {
134
+ const meta = readMeta(file);
135
+ if (!meta || !meta.cwd)
136
+ continue;
137
+ if (meta.cwd !== project && !meta.cwd.startsWith(project + path.sep))
138
+ continue;
139
+ const events = parseEvents(file, sinceMs, untilMs, meta.interactive);
140
+ if (events.length > 0) {
141
+ sessions.push({ name: `codex:${path.relative(baseDir, file)}`, events });
142
+ }
143
+ }
144
+ return sessions;
145
+ }