@jagit/hook-codex 0.0.2 → 0.0.3
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 +43 -15
- package/dist/index.d.ts +46 -0
- package/dist/index.js +115 -23
- package/package.json +2 -2
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
|
|
8
|
-
|
|
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
|
|
41
|
+
local exit_code=$?
|
|
13
42
|
npx -y @jagit/hook-codex >/dev/null 2>&1 || true
|
|
14
|
-
return $
|
|
43
|
+
return $exit_code
|
|
15
44
|
}
|
|
16
45
|
|
|
17
|
-
Add that function to your shell rc (`~/.zshrc`, `~/.bashrc`, etc.)
|
|
18
|
-
|
|
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
|
-
-
|
|
41
|
-
|
|
42
|
-
-
|
|
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 ??
|
|
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
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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.
|
|
3
|
+
"version": "0.0.3",
|
|
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.
|
|
12
|
+
"@jagit/agent-reporter": "0.0.3"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"@types/node": "^25.9.3",
|