@jagit/hook-codex 0.0.2 → 0.0.4

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/README.md CHANGED
@@ -2,26 +2,50 @@
2
2
 
3
3
  Reports per-session OpenAI Codex CLI usage to JaGit.
4
4
 
5
- ## Setup
5
+ ## Setup (recommended — native Codex hook)
6
6
 
7
- Codex has no built-in hook mechanism, so install a shell function that wraps
8
- the real `codex` binary and reports after each session ends:
7
+ Codex CLI has a built-in hook mechanism. Add a `Stop` hook to
8
+ `~/.codex/hooks.json` (user-level) or `.codex/hooks.json` (repo-level):
9
+
10
+ ```json
11
+ {
12
+ "hooks": {
13
+ "Stop": [
14
+ {
15
+ "hooks": [
16
+ {
17
+ "type": "command",
18
+ "command": "JAGIT_BASE_URL=https://your-jagit-host JAGIT_API_KEY=<your-token> npx -y @jagit/hook-codex",
19
+ "timeout": 30
20
+ }
21
+ ]
22
+ }
23
+ ]
24
+ }
25
+ }
26
+ ```
27
+
28
+ Codex passes the session payload on stdin (including `session_id`, `model`,
29
+ `cwd`, `transcript_path`, and `stop_hook_active`). The hook reads token usage
30
+ from the transcript and reports the session to JaGit. The `stop_hook_active`
31
+ guard prevents duplicate reports when Codex re-runs the hook after a
32
+ continuation.
33
+
34
+ ## Setup (legacy — shell wrapper)
35
+
36
+ If you are on an older Codex CLI version without hook support, install a shell
37
+ function that wraps the real `codex` binary and reports after each session ends:
9
38
 
10
39
  codex() {
11
40
  command codex "$@"
12
- local status=$?
41
+ local exit_code=$?
13
42
  npx -y @jagit/hook-codex >/dev/null 2>&1 || true
14
- return $status
43
+ return $exit_code
15
44
  }
16
45
 
17
- Add that function to your shell rc (`~/.zshrc`, `~/.bashrc`, etc.) it must
18
- appear before any `alias`/`PATH` entry that would otherwise shadow it. On
19
- exit, the reporter locates the most-recently-modified file under
46
+ Add that function to your shell rc (`~/.zshrc`, `~/.bashrc`, etc.). On exit,
47
+ the reporter locates the most-recently-modified file under
20
48
  `~/.codex/sessions/**/*.jsonl`, parses it, and posts the session summary.
21
- Uninstall by removing the shell function.
22
-
23
- For a permanent binary instead of `npx -y`:
24
- `npm i -g @jagit/hook-codex`, then call `jagit-hook-codex` in the wrapper.
25
49
 
26
50
  You can also point the reporter at a specific transcript:
27
51
 
@@ -37,6 +61,10 @@ override with `JAGIT_GIT_USERNAME`.
37
61
 
38
62
  ## Notes
39
63
 
40
- - Token counts are cumulative per Codex session; the reporter reads the last
41
- non-null `token_count` event, not a sum across events.
42
- - `costUsd` is always `null` Codex JSONL logs do not expose a cost field.
64
+ - In native hook mode, `model` is read directly from the Codex stdin payload.
65
+ - In legacy mode, `model` is parsed from `turn_context` records in the JSONL file.
66
+ - Token counts in legacy mode are cumulative per Codex session; the reporter
67
+ reads the last non-null `token_count` event, not a sum across events.
68
+ - In native hook mode, token counts are summed across all assistant transcript
69
+ entries (each entry represents one turn).
70
+ - `costUsd` is always `null` — Codex logs do not expose a cost field.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  import { type AgentSessionPayload } from "@jagit/agent-reporter";
3
+ export interface CodexStopStdin {
4
+ /** Current Codex session id */
5
+ session_id: string;
6
+ /** Working directory for the session */
7
+ cwd: string;
8
+ /** Always "Stop" for this event */
9
+ hook_event_name: string;
10
+ /** Active model slug — Codex-specific extension */
11
+ model: string;
12
+ /** Active Codex turn id — Codex-specific extension */
13
+ turn_id: string;
14
+ /** Whether this turn was already continued by a Stop hook (prevents loops) */
15
+ stop_hook_active: boolean;
16
+ /** Path to the session transcript file, or null */
17
+ transcript_path: string | null;
18
+ /** Latest assistant message text, or null */
19
+ last_assistant_message: string | null;
20
+ /** Current permission mode */
21
+ permission_mode: "default" | "acceptEdits" | "plan" | "dontAsk" | "bypassPermissions";
22
+ }
3
23
  export interface CodexRecord {
4
24
  type?: string;
5
25
  timestamp?: string;
@@ -18,4 +38,30 @@ export interface CodexRecord {
18
38
  } | null;
19
39
  };
20
40
  }
41
+ export interface CodexTranscriptEntry {
42
+ type?: string;
43
+ timestamp?: string;
44
+ message?: {
45
+ role?: string;
46
+ model?: string;
47
+ content?: unknown;
48
+ usage?: {
49
+ input_tokens?: number;
50
+ output_tokens?: number;
51
+ cached_tokens?: number;
52
+ inputTokens?: number;
53
+ outputTokens?: number;
54
+ cachedTokens?: number;
55
+ };
56
+ };
57
+ }
58
+ /**
59
+ * Build payload from a real Codex Stop-hook stdin.
60
+ * Model is read directly from stdin; transcript is parsed for token usage.
61
+ */
62
+ export declare function buildPayloadFromStdin(stdin: CodexStopStdin, read?: (path: string) => CodexTranscriptEntry[]): AgentSessionPayload;
63
+ /**
64
+ * Build payload from legacy JSONL session records (file-scan fallback).
65
+ * Used when hook-codex is invoked via the old shell-wrapper pattern.
66
+ */
21
67
  export declare function buildPayload(sessionId: string, cwd: string | undefined, records: CodexRecord[]): AgentSessionPayload;
package/dist/index.js CHANGED
@@ -6,6 +6,73 @@ import { join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { resolveGitUsername, reportSession } from "@jagit/agent-reporter";
8
8
  const TOOL_CALL_SUBTYPES = new Set(["function_call", "custom_tool_call", "web_search_call"]);
9
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
10
+ function readJsonl(path) {
11
+ return readFileSync(path, "utf-8")
12
+ .split("\n")
13
+ .filter((l) => l.trim().length > 0)
14
+ .map((l) => {
15
+ try {
16
+ return JSON.parse(l);
17
+ }
18
+ catch {
19
+ return {};
20
+ }
21
+ });
22
+ }
23
+ function hasToolUse(content) {
24
+ return Array.isArray(content) && content.some((b) => b?.type === "tool_use");
25
+ }
26
+ // ─── Payload builders ─────────────────────────────────────────────────────────
27
+ /**
28
+ * Build payload from a real Codex Stop-hook stdin.
29
+ * Model is read directly from stdin; transcript is parsed for token usage.
30
+ */
31
+ export function buildPayloadFromStdin(stdin, read = (p) => readJsonl(p)) {
32
+ let inputTokens = 0;
33
+ let cachedInputTokens = 0;
34
+ let outputTokens = 0;
35
+ let toolCallCount = 0;
36
+ const entries = stdin.transcript_path
37
+ ? (() => {
38
+ try {
39
+ return read(stdin.transcript_path);
40
+ }
41
+ catch {
42
+ return [];
43
+ }
44
+ })()
45
+ : [];
46
+ for (const e of entries) {
47
+ if (e.message?.role !== "assistant")
48
+ continue;
49
+ if (hasToolUse(e.message.content))
50
+ toolCallCount += 1;
51
+ const u = e.message.usage;
52
+ if (u) {
53
+ inputTokens += u.input_tokens ?? u.inputTokens ?? 0;
54
+ cachedInputTokens += u.cached_tokens ?? u.cachedTokens ?? 0;
55
+ outputTokens += u.output_tokens ?? u.outputTokens ?? 0;
56
+ }
57
+ }
58
+ const startedAt = entries.find((e) => e.timestamp)?.timestamp ?? new Date().toISOString();
59
+ return {
60
+ tool: "codex",
61
+ sessionId: stdin.session_id,
62
+ gitUsername: resolveGitUsername(stdin.cwd),
63
+ model: stdin.model,
64
+ inputTokens,
65
+ cachedInputTokens,
66
+ outputTokens,
67
+ costUsd: null,
68
+ toolCallCount,
69
+ startedAt,
70
+ };
71
+ }
72
+ /**
73
+ * Build payload from legacy JSONL session records (file-scan fallback).
74
+ * Used when hook-codex is invoked via the old shell-wrapper pattern.
75
+ */
9
76
  export function buildPayload(sessionId, cwd, records) {
10
77
  let model = "unknown";
11
78
  let inputTokens = 0;
@@ -19,6 +86,7 @@ export function buildPayload(sessionId, cwd, records) {
19
86
  if (r.type === "event_msg" && r.payload?.type === "token_count" && r.payload.info) {
20
87
  const usage = r.payload.info.total_token_usage;
21
88
  if (usage) {
89
+ // Codex token_count events are cumulative — take the last non-null value
22
90
  inputTokens = usage.input_tokens ?? 0;
23
91
  cachedInputTokens = usage.cached_input_tokens ?? 0;
24
92
  outputTokens = usage.output_tokens ?? 0;
@@ -29,7 +97,9 @@ export function buildPayload(sessionId, cwd, records) {
29
97
  }
30
98
  }
31
99
  const sessionMeta = records.find((r) => r.type === "session_meta");
32
- const startedAt = records.find((r) => r.timestamp)?.timestamp ?? sessionMeta?.payload?.timestamp ?? new Date().toISOString();
100
+ const startedAt = records.find((r) => r.timestamp)?.timestamp ??
101
+ sessionMeta?.payload?.timestamp ??
102
+ new Date().toISOString();
33
103
  return {
34
104
  tool: "codex",
35
105
  sessionId,
@@ -43,19 +113,7 @@ export function buildPayload(sessionId, cwd, records) {
43
113
  startedAt,
44
114
  };
45
115
  }
46
- function readJsonl(path) {
47
- return readFileSync(path, "utf-8")
48
- .split("\n")
49
- .filter((l) => l.trim().length > 0)
50
- .map((l) => {
51
- try {
52
- return JSON.parse(l);
53
- }
54
- catch {
55
- return {};
56
- }
57
- });
58
- }
116
+ // ─── Legacy file-scan helpers ─────────────────────────────────────────────────
59
117
  function findLatestSessionFile(root) {
60
118
  let latestPath;
61
119
  let latestMtime = -Infinity;
@@ -95,17 +153,51 @@ function parseArgs(argv) {
95
153
  }
96
154
  return {};
97
155
  }
156
+ // ─── Try to read a JSON object from stdin (fd 0) ──────────────────────────────
157
+ function tryReadStdin() {
158
+ try {
159
+ const raw = readFileSync(0, "utf-8").trim();
160
+ if (!raw)
161
+ return undefined;
162
+ const parsed = JSON.parse(raw);
163
+ // Accept any object that looks like a Codex Stop hook payload
164
+ if (typeof parsed === "object" &&
165
+ parsed !== null &&
166
+ parsed["hook_event_name"] === "Stop" &&
167
+ typeof parsed["session_id"] === "string") {
168
+ return parsed;
169
+ }
170
+ return undefined;
171
+ }
172
+ catch {
173
+ return undefined;
174
+ }
175
+ }
176
+ // ─── Entry point ──────────────────────────────────────────────────────────────
98
177
  async function main() {
99
178
  try {
100
- const { file } = parseArgs(process.argv.slice(2));
101
- const filePath = file ?? findLatestSessionFile(join(homedir(), ".codex", "sessions"));
102
- if (!filePath)
103
- return;
104
- const records = readJsonl(filePath);
105
- const sessionMeta = records.find((r) => r.type === "session_meta");
106
- const sessionId = sessionMeta?.payload?.id ?? filePath;
107
- const cwd = sessionMeta?.payload?.cwd;
108
- await reportSession(buildPayload(sessionId, cwd, records));
179
+ const stdin = tryReadStdin();
180
+ // stop_hook_active=true means the agent is re-running because a previous Stop hook
181
+ // blocked it. Skip reporting to avoid duplicate session entries.
182
+ if (stdin?.stop_hook_active === true) {
183
+ process.exit(0);
184
+ }
185
+ if (stdin) {
186
+ // Real Codex hook mode — stdin has session_id, model, transcript_path
187
+ await reportSession(buildPayloadFromStdin(stdin));
188
+ }
189
+ else {
190
+ // Legacy shell-wrapper mode — scan ~/.codex/sessions/**/*.jsonl
191
+ const { file } = parseArgs(process.argv.slice(2));
192
+ const filePath = file ?? findLatestSessionFile(join(homedir(), ".codex", "sessions"));
193
+ if (!filePath)
194
+ return;
195
+ const records = readJsonl(filePath);
196
+ const sessionMeta = records.find((r) => r.type === "session_meta");
197
+ const sessionId = sessionMeta?.payload?.id ?? filePath;
198
+ const cwd = sessionMeta?.payload?.cwd;
199
+ await reportSession(buildPayload(sessionId, cwd, records));
200
+ }
109
201
  }
110
202
  catch (err) {
111
203
  console.error("[hook-codex]", err instanceof Error ? err.message : err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jagit/hook-codex",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "jagit-hook-codex": "dist/index.js"
@@ -9,7 +9,7 @@
9
9
  "dist"
10
10
  ],
11
11
  "dependencies": {
12
- "@jagit/agent-reporter": "0.0.2"
12
+ "@jagit/agent-reporter": "0.0.4"
13
13
  },
14
14
  "devDependencies": {
15
15
  "@types/node": "^25.9.3",