@kennykeni/agent-trace 0.1.2 → 0.1.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 CHANGED
@@ -49,13 +49,16 @@ bunx @kennykeni/agent-trace status
49
49
 
50
50
  ## What `init` does
51
51
 
52
- Configures the target repo's provider settings:
52
+ Creates `.agent-trace/config.json` with default settings and configures the target repo's provider hooks:
53
53
 
54
- | Provider | Config written |
55
- | ---------- | ----------------------------------------- |
56
- | Cursor | `.cursor/hooks.json` |
57
- | Claude Code| `.claude/settings.json` |
58
- | OpenCode | `.opencode/plugins/agent-trace.ts` |
54
+ | File | Purpose |
55
+ | --------------------------------------- | -------------------------------- |
56
+ | `.agent-trace/config.json` | Extensions and ignore settings |
57
+ | `.cursor/hooks.json` | Cursor hook registration |
58
+ | `.claude/settings.json` | Claude Code hook registration |
59
+ | `.opencode/plugins/agent-trace.ts` | OpenCode plugin registration |
60
+
61
+ Existing `config.json` files are never overwritten — only created when absent.
59
62
 
60
63
  ## How it works
61
64
 
@@ -72,20 +75,49 @@ Additional artifacts are written by extensions under `.agent-trace/`:
72
75
  - `diffs/<provider>/<session>.patch` -- diff artifacts when available (`diffs` extension)
73
76
  - `line-hashes/<provider>/<session>.jsonl` -- per-line content hashes (`line-hashes` extension)
74
77
 
75
- ## Extensions
76
-
77
- Extensions are pluggable modules that run alongside the core trace pipeline. Four are built in: `raw-events`, `diffs`, `messages`, and `line-hashes`. All extensions are enabled by default.
78
+ ## Configuration
78
79
 
79
- To control which extensions run, create `.agent-trace/config.json` in your project root:
80
+ `init` generates `.agent-trace/config.json` with these defaults:
80
81
 
81
82
  ```json
82
- { "extensions": ["diffs", "messages"] }
83
+ {
84
+ "extensions": ["diffs", "line-hashes", "raw-events", "messages"],
85
+ "useGitignore": true,
86
+ "useBuiltinSensitive": true,
87
+ "ignore": [],
88
+ "ignoreMode": "redact"
89
+ }
83
90
  ```
84
91
 
85
- - **File absent** -- all registered extensions run (default)
92
+ ### Extensions
93
+
94
+ Extensions are pluggable modules that run alongside the core trace pipeline. Four are built in: `raw-events`, `diffs`, `messages`, and `line-hashes`.
95
+
86
96
  - **`"extensions": ["diffs", "messages"]`** -- only listed extensions run
87
97
  - **`"extensions": []`** -- no extensions run (only `traces.jsonl` is written)
88
- - **Malformed JSON** -- warning logged, all extensions run
98
+
99
+ ### Sensitive file filtering
100
+
101
+ By default, agent-trace filters sensitive files to prevent secrets from leaking into trace artifacts. Filtering applies to `traces.jsonl`, diffs, line-hashes, and raw events.
102
+
103
+ | Field | Default | Description |
104
+ |-------|---------|-------------|
105
+ | `useGitignore` | `true` | Respect `.gitignore` patterns via `git check-ignore` |
106
+ | `useBuiltinSensitive` | `true` | Apply built-in sensitive file patterns |
107
+ | `ignore` | `[]` | Additional glob patterns to filter |
108
+ | `ignoreMode` | `"redact"` | `"redact"` keeps the trace entry with path but no content; `"skip"` drops the event entirely |
109
+
110
+ Built-in sensitive patterns match at any depth:
111
+
112
+ ```
113
+ .env .env.* *.pem *.key *.p12 *.pfx
114
+ id_rsa id_dsa id_ecdsa id_ed25519
115
+ *.kubeconfig credentials.*
116
+ ```
117
+
118
+ When a file is **redacted**, the trace records the file path with empty ranges and `metadata.redacted: true`. Extensions see empty edits and produce no diff/hash artifacts. Raw events have sensitive fields (`old_string`, `new_string`, `content`, `before`, `after`, `originalFile`) replaced with `"[REDACTED]"`.
119
+
120
+ When a file is **skipped**, no trace entry, diff, hash, or raw event is written.
89
121
 
90
122
  ## Trace format
91
123
 
@@ -105,7 +137,33 @@ Schema source: [`schemas.ts`](./src/core/schemas.ts)
105
137
 
106
138
  - **indexOf-based range attribution**: When the same text appears multiple times in a file, line-range attribution may point to the first occurrence rather than the actual edit location. Providers don't always supply line numbers, so `indexOf` is the best-effort fallback.
107
139
  - **Bun-only**: The hook runtime and CLI require Bun. Node.js is not supported.
108
- - **No VCS requirement**: Works without git. When git is available, traces include the current commit SHA. Without git, VCS info is omitted.
140
+ - **No VCS requirement**: Works without git. When git is available, traces include the current commit SHA. Without git, VCS info is omitted. `useGitignore` silently becomes a no-op in non-git repos.
141
+ - **Multi-file OpenCode events**: If any file in a `hook:tool.execute.after` payload is ignored, the entire raw event is redacted/skipped (conservative approach).
142
+ - **`.env.*` matches broadly**: `**/.env.*` matches `.env.example` and `.env.template` intentionally — these files sometimes contain real values.
143
+
144
+ ## Provider quirks
145
+
146
+ ### Cursor
147
+
148
+ - **Tab edits lack file content**: `afterTabFileEdit` events do not set `readContent`, so line-hashes for tab completions have no file context for position resolution.
149
+ - **Duration field ambiguity**: Shell events accept both `duration` and `duration_ms`. When both are present, `duration_ms` takes precedence.
150
+
151
+ ### Claude Code
152
+
153
+ - **Model tracking limited to session start**: Claude Code only includes the `model` field in `SessionStart` hook payloads. Switching models mid-session via `/model` does not fire a hook event, so traces after a switch may reflect the original model. This is a Claude Code hook API limitation.
154
+ - **Only Write, Edit, and Bash traced**: Other tool uses (Read, Search, etc.) are not hooked and produce no trace events.
155
+ - **Write tool fallback**: When the `Write` tool payload has no `new_string`, falls back to `content`. When neither is present, an empty-edits trace is recorded.
156
+
157
+ ### OpenCode
158
+
159
+ - **Two file-edit code paths**: `file.edited` events carry no diff data (`edits: []`, `diffs: false`). Only `hook:tool.execute.after` events include before/after content for diffs and line-hashes.
160
+ - **Flexible session ID extraction**: Session IDs can appear in five different payload locations depending on the event type. The adapter tries them all in priority order.
161
+
162
+ ### Codex
163
+
164
+ - **Only `apply_patch` tool calls traced**: File changes from shell commands or other mechanisms are not detected. Same contract as other providers — only explicit tool-reported edits are traced.
165
+ - **Patch format stability**: `parsePatchInput` depends on Codex's `*** <Action> File:` patch grammar. If Codex changes the format, parsing fails silently.
166
+ - **`*** Move to:` (rename) blocks not parsed**: Rename operations in apply_patch are not traced. This is a rare edge case.
109
167
 
110
168
  ## Development
111
169
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kennykeni/agent-trace",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "agent-trace": "src/cli.ts"
@@ -22,10 +22,12 @@
22
22
  "test": "bun test"
23
23
  },
24
24
  "dependencies": {
25
+ "diff": "^8.0.3",
25
26
  "zod": "^3.24.0"
26
27
  },
27
28
  "devDependencies": {
28
29
  "@biomejs/biome": "^2.3.14",
29
- "@types/bun": "latest"
30
+ "@types/bun": "latest",
31
+ "@types/diff": "^8.0.0"
30
32
  }
31
33
  }
package/src/cli.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { existsSync, readFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
4
5
  import { join } from "node:path";
5
6
  import { runHook } from "./core/trace-hook";
6
7
  import "./extensions";
@@ -21,21 +22,27 @@ Usage:
21
22
  agent-trace <command> [options]
22
23
 
23
24
  Commands:
24
- init Initialize hooks for Cursor, Claude Code, and OpenCode in a project
25
+ init Initialize hooks for Cursor, Claude Code, OpenCode, and Codex
25
26
  hook Run the trace hook (reads JSON from stdin)
27
+ codex Codex subcommands (notify, ingest, exec)
26
28
  status Show installed hook status
27
29
  help Show this help message
28
30
 
29
31
  Init options:
30
- --providers <list> Comma-separated providers (cursor,claude,opencode) [default: all]
32
+ --providers <list> Comma-separated providers (cursor,claude,opencode,codex) [default: all]
31
33
  --target-root <dir> Target project root [default: current directory]
32
34
  --dry-run Preview changes without writing
33
35
  --latest Use latest version instead of pinning to current
34
36
 
37
+ Codex subcommands:
38
+ codex notify '<json>' Handle Codex notify callback
39
+ codex ingest Read Codex JSONL from stdin
40
+ codex exec [args...] Wrap codex exec --json with tracing
41
+
35
42
  Examples:
36
43
  agent-trace init
37
44
  agent-trace init --providers cursor
38
- agent-trace init --providers opencode
45
+ agent-trace init --providers codex
39
46
  agent-trace init --target-root ~/my-project
40
47
  agent-trace status`);
41
48
  }
@@ -53,6 +60,12 @@ function checkHookConfig(
53
60
  }
54
61
  }
55
62
 
63
+ function codexConfigStatus(): "installed" | "not installed" {
64
+ const home = process.env.CODEX_HOME ?? join(homedir(), ".codex");
65
+ const configPath = join(home, "config.toml");
66
+ return checkHookConfig(configPath, "agent-trace");
67
+ }
68
+
56
69
  function status(): void {
57
70
  const root = getWorkspaceRoot();
58
71
 
@@ -69,6 +82,7 @@ function status(): void {
69
82
  "agent-trace hook --provider claude",
70
83
  );
71
84
  const opencodeStatus = checkHookConfig(opencodePath, "agent-trace");
85
+ const codexStatus = codexConfigStatus();
72
86
  const traceDir = join(root, ".agent-trace");
73
87
  const hasTraces = existsSync(join(traceDir, "traces.jsonl"));
74
88
 
@@ -76,6 +90,7 @@ function status(): void {
76
90
  console.log(`Cursor: ${cursorStatus}`);
77
91
  console.log(`Claude: ${claudeStatus}`);
78
92
  console.log(`OpenCode: ${opencodeStatus}`);
93
+ console.log(`Codex: ${codexStatus}`);
79
94
  console.log(`Traces: ${hasTraces ? "present" : "none"}`);
80
95
  }
81
96
 
@@ -99,6 +114,12 @@ switch (command) {
99
114
  case "hook":
100
115
  await runHook();
101
116
  break;
117
+ case "codex": {
118
+ const { runCodexSubcommand } = await import("./codex");
119
+ const exitCode = await runCodexSubcommand(process.argv.slice(3));
120
+ process.exit(exitCode);
121
+ break;
122
+ }
102
123
  case "status":
103
124
  status();
104
125
  break;
@@ -0,0 +1,56 @@
1
+ import "../extensions";
2
+ import { CodexTraceIngestor } from "./ingestor";
3
+ import { handleNotify } from "./notify";
4
+ import { streamLines } from "./stream";
5
+
6
+ async function ingestCodexJsonFromStdin(): Promise<number> {
7
+ const ingestor = new CodexTraceIngestor();
8
+
9
+ await streamLines(Bun.stdin.stream(), (line) => {
10
+ ingestor.processLine(line);
11
+ });
12
+
13
+ return 0;
14
+ }
15
+
16
+ async function runCodexExecWithTracing(args: string[]): Promise<number> {
17
+ const proc = Bun.spawn(["codex", "exec", "--json", ...args], {
18
+ stdin: "inherit",
19
+ stderr: "inherit",
20
+ stdout: "pipe",
21
+ });
22
+
23
+ const ingestor = new CodexTraceIngestor();
24
+
25
+ await streamLines(proc.stdout, (line) => {
26
+ process.stdout.write(`${line}\n`);
27
+ ingestor.processLine(line);
28
+ });
29
+
30
+ const exitCode = await proc.exited;
31
+ return exitCode;
32
+ }
33
+
34
+ export async function runCodexSubcommand(args: string[]): Promise<number> {
35
+ const sub = args[0];
36
+
37
+ switch (sub) {
38
+ case "notify":
39
+ if (!args[1]) {
40
+ console.error("Usage: agent-trace codex notify '<json>'");
41
+ return 1;
42
+ }
43
+ return handleNotify(args[1]);
44
+
45
+ case "ingest":
46
+ return ingestCodexJsonFromStdin();
47
+
48
+ case "exec":
49
+ return runCodexExecWithTracing(args.slice(1));
50
+
51
+ default:
52
+ console.error(`Unknown codex subcommand: ${sub ?? "(none)"}`);
53
+ console.error("Available: notify, ingest, exec");
54
+ return 1;
55
+ }
56
+ }
@@ -0,0 +1,427 @@
1
+ import { join, resolve } from "node:path";
2
+ import type { IgnoreConfig } from "../core/ignore";
3
+ import { loadConfig } from "../core/ignore";
4
+ import { activeExtensions, dispatchTraceEvent } from "../core/trace-hook";
5
+ import { getWorkspaceRoot } from "../core/trace-store";
6
+ import type { FileEdit, TraceEvent } from "../core/types";
7
+ import { appendJsonl, sanitizeSessionId } from "../extensions/helpers";
8
+
9
+ const TOOL = { name: "codex-cli" } as const;
10
+
11
+ interface HunkLine {
12
+ type: "context" | "del" | "add";
13
+ text: string;
14
+ }
15
+
16
+ const CONTEXT_LINES = 3;
17
+ const MERGE_GAP = 2 * CONTEXT_LINES;
18
+
19
+ export function clusterHunkLines(lines: HunkLine[]): FileEdit[] {
20
+ const changeIndices: number[] = [];
21
+ for (let i = 0; i < lines.length; i++) {
22
+ const line = lines[i];
23
+ if (line && line.type !== "context") changeIndices.push(i);
24
+ }
25
+ if (changeIndices.length === 0) return [];
26
+
27
+ const regions: { start: number; end: number }[] = [];
28
+ const first = changeIndices[0];
29
+ if (first === undefined) return [];
30
+ let rStart = first;
31
+ let rEnd = rStart;
32
+
33
+ for (let i = 1; i < changeIndices.length; i++) {
34
+ const idx = changeIndices[i];
35
+ if (idx === undefined) continue;
36
+ if (idx === rEnd + 1) {
37
+ rEnd = idx;
38
+ } else {
39
+ let allContext = true;
40
+ for (let j = rEnd + 1; j < idx; j++) {
41
+ const jLine = lines[j];
42
+ if (jLine && jLine.type !== "context") {
43
+ allContext = false;
44
+ break;
45
+ }
46
+ }
47
+ if (allContext) {
48
+ regions.push({ start: rStart, end: rEnd });
49
+ rStart = idx;
50
+ rEnd = idx;
51
+ } else {
52
+ rEnd = idx;
53
+ }
54
+ }
55
+ }
56
+ regions.push({ start: rStart, end: rEnd });
57
+
58
+ const firstRegion = regions[0];
59
+ if (!firstRegion) return [];
60
+ const merged: { start: number; end: number }[] = [firstRegion];
61
+ for (let i = 1; i < regions.length; i++) {
62
+ const prev = merged[merged.length - 1];
63
+ const curr = regions[i];
64
+ if (!prev || !curr) continue;
65
+ const gap = curr.start - prev.end - 1;
66
+ if (gap <= MERGE_GAP) {
67
+ prev.end = curr.end;
68
+ } else {
69
+ merged.push({ ...curr });
70
+ }
71
+ }
72
+
73
+ const edits: FileEdit[] = [];
74
+ for (const region of merged) {
75
+ const ctxStart = Math.max(0, region.start - CONTEXT_LINES);
76
+ const ctxEnd = Math.min(lines.length - 1, region.end + CONTEXT_LINES);
77
+
78
+ const oldParts: string[] = [];
79
+ const newParts: string[] = [];
80
+ for (let i = ctxStart; i <= ctxEnd; i++) {
81
+ const l = lines[i];
82
+ if (!l) continue;
83
+ if (l.type === "context") {
84
+ oldParts.push(l.text);
85
+ newParts.push(l.text);
86
+ } else if (l.type === "del") {
87
+ oldParts.push(l.text);
88
+ } else {
89
+ newParts.push(l.text);
90
+ }
91
+ }
92
+
93
+ edits.push({
94
+ old_string: oldParts.join("\n"),
95
+ new_string: newParts.join("\n"),
96
+ });
97
+ }
98
+
99
+ return edits;
100
+ }
101
+
102
+ export interface IngestorState {
103
+ sessionId: string | undefined;
104
+ modelId: string | undefined;
105
+ turnIndex: number;
106
+ sessionStarted: boolean;
107
+ sessionEnded: boolean;
108
+ pendingUserPrompt: string | undefined;
109
+ lastAgentMessage: string | undefined;
110
+ }
111
+
112
+ interface RolloutLine {
113
+ timestamp?: string;
114
+ type: string;
115
+ payload: Record<string, unknown>;
116
+ }
117
+
118
+ export function parsePatchInput(input: string): Map<string, FileEdit[]> {
119
+ const result = new Map<string, FileEdit[]>();
120
+ const filePattern = /^\*\*\*\s+(?:Update|Add|Delete)\s+File:\s+(.+)$/;
121
+ const lines = input.split("\n");
122
+ let i = 0;
123
+
124
+ while (i < lines.length) {
125
+ const line = lines[i] ?? "";
126
+ const fileMatch = filePattern.exec(line);
127
+ if (!fileMatch) {
128
+ i++;
129
+ continue;
130
+ }
131
+
132
+ const filePath = fileMatch[1]?.trim();
133
+ if (!filePath) {
134
+ i++;
135
+ continue;
136
+ }
137
+
138
+ i++;
139
+ const edits: FileEdit[] = [];
140
+
141
+ // Collect +lines between file header and first @@ (for Add File without hunk header)
142
+ const looseNewLines: string[] = [];
143
+
144
+ while (i < lines.length) {
145
+ const cur = lines[i] ?? "";
146
+ if (cur.startsWith("***")) break;
147
+
148
+ if (cur.startsWith("@@")) {
149
+ i++;
150
+ const hunkLines: HunkLine[] = [];
151
+
152
+ while (i < lines.length) {
153
+ const dl = lines[i] ?? "";
154
+ if (dl.startsWith("@@") || dl.startsWith("***")) break;
155
+ if (dl.startsWith("-")) {
156
+ hunkLines.push({ type: "del", text: dl.slice(1) });
157
+ } else if (dl.startsWith("+")) {
158
+ hunkLines.push({ type: "add", text: dl.slice(1) });
159
+ } else if (dl.startsWith(" ")) {
160
+ hunkLines.push({ type: "context", text: dl.slice(1) });
161
+ }
162
+ i++;
163
+ }
164
+
165
+ edits.push(...clusterHunkLines(hunkLines));
166
+ continue;
167
+ }
168
+
169
+ if (cur.startsWith("+")) {
170
+ looseNewLines.push(cur.slice(1));
171
+ }
172
+
173
+ i++;
174
+ }
175
+
176
+ if (looseNewLines.length > 0 && edits.length === 0) {
177
+ edits.push({
178
+ old_string: "",
179
+ new_string: looseNewLines.join("\n"),
180
+ });
181
+ }
182
+
183
+ const existing = result.get(filePath) ?? [];
184
+ result.set(filePath, [...existing, ...edits]);
185
+ }
186
+
187
+ return result;
188
+ }
189
+
190
+ export class CodexTraceIngestor {
191
+ sessionId: string | undefined;
192
+ modelId: string | undefined;
193
+ private transcriptPath: string | undefined;
194
+ private cachedIgnoreConfig: IgnoreConfig | undefined;
195
+ turnIndex = 0;
196
+ sessionStarted = false;
197
+ sessionEnded = false;
198
+ pendingUserPrompt: string | undefined;
199
+ lastAgentMessage: string | undefined;
200
+
201
+ constructor(transcriptPath?: string) {
202
+ this.transcriptPath = transcriptPath;
203
+ }
204
+
205
+ private getIgnoreConfig(): IgnoreConfig {
206
+ if (!this.cachedIgnoreConfig) {
207
+ this.cachedIgnoreConfig = loadConfig(getWorkspaceRoot()).ignore;
208
+ }
209
+ return this.cachedIgnoreConfig;
210
+ }
211
+
212
+ private emitTraceEvent(event: TraceEvent): void {
213
+ if (!this.sessionId) return;
214
+ const root = getWorkspaceRoot();
215
+ const config = loadConfig(root);
216
+ const extensions = activeExtensions(config.extensions);
217
+ dispatchTraceEvent(event, extensions, TOOL, this.getIgnoreConfig());
218
+ }
219
+
220
+ restoreState(state: IngestorState): void {
221
+ this.sessionId = state.sessionId;
222
+ this.modelId = state.modelId;
223
+ this.turnIndex = state.turnIndex;
224
+ this.sessionStarted = state.sessionStarted;
225
+ this.sessionEnded = state.sessionEnded;
226
+ this.pendingUserPrompt = state.pendingUserPrompt;
227
+ this.lastAgentMessage = state.lastAgentMessage;
228
+ }
229
+
230
+ snapshotState(): IngestorState {
231
+ return {
232
+ sessionId: this.sessionId,
233
+ modelId: this.modelId,
234
+ turnIndex: this.turnIndex,
235
+ sessionStarted: this.sessionStarted,
236
+ sessionEnded: this.sessionEnded,
237
+ pendingUserPrompt: this.pendingUserPrompt,
238
+ lastAgentMessage: this.lastAgentMessage,
239
+ };
240
+ }
241
+
242
+ processLine(line: string): void {
243
+ let parsed: RolloutLine;
244
+ try {
245
+ parsed = JSON.parse(line) as RolloutLine;
246
+ } catch {
247
+ return;
248
+ }
249
+
250
+ const outerType = parsed.type;
251
+ if (!outerType || !parsed.payload) return;
252
+
253
+ switch (outerType) {
254
+ case "session_meta":
255
+ this.onSessionMeta(parsed.payload);
256
+ break;
257
+ case "turn_context":
258
+ this.onTurnContext(parsed.payload);
259
+ break;
260
+ case "event_msg":
261
+ this.onEventMsg(parsed.payload);
262
+ break;
263
+ case "response_item":
264
+ this.onResponseItem(parsed.payload);
265
+ break;
266
+ }
267
+
268
+ this.appendRaw(parsed);
269
+ }
270
+
271
+ private appendRaw(event: RolloutLine): void {
272
+ if (!this.sessionId) return;
273
+ const root = getWorkspaceRoot();
274
+ const sid = sanitizeSessionId(this.sessionId);
275
+ const path = join(root, ".agent-trace", "raw", "codex", `${sid}.jsonl`);
276
+ appendJsonl(path, {
277
+ timestamp: new Date().toISOString(),
278
+ provider: "codex",
279
+ session_id: sid,
280
+ event,
281
+ });
282
+ }
283
+
284
+ private onSessionMeta(payload: Record<string, unknown>): void {
285
+ this.sessionId = (payload.id as string) ?? this.sessionId;
286
+ this.sessionStarted = true;
287
+
288
+ this.emitTraceEvent({
289
+ kind: "session_start",
290
+ provider: "codex",
291
+ sessionId: this.sessionId,
292
+ model: this.modelId,
293
+ meta: {
294
+ codex_session_id: this.sessionId,
295
+ cli_version: payload.cli_version,
296
+ model_provider: payload.model_provider,
297
+ },
298
+ });
299
+ }
300
+
301
+ private onTurnContext(payload: Record<string, unknown>): void {
302
+ const model = payload.model as string | undefined;
303
+ if (model) this.modelId = model;
304
+ this.turnIndex++;
305
+ }
306
+
307
+ private onEventMsg(payload: Record<string, unknown>): void {
308
+ const innerType = payload.type as string | undefined;
309
+ if (!innerType) return;
310
+
311
+ switch (innerType) {
312
+ case "user_message": {
313
+ const message = (payload.message as string) ?? undefined;
314
+ this.pendingUserPrompt = message;
315
+ if (message) {
316
+ this.emitTraceEvent({
317
+ kind: "message",
318
+ provider: "codex",
319
+ sessionId: this.sessionId,
320
+ role: "user",
321
+ content: message,
322
+ eventName: "user_message",
323
+ model: this.modelId,
324
+ meta: {
325
+ codex_session_id: this.sessionId,
326
+ turn_index: this.turnIndex,
327
+ },
328
+ });
329
+ }
330
+ break;
331
+ }
332
+ case "agent_message": {
333
+ const message = (payload.message as string) ?? undefined;
334
+ this.lastAgentMessage = message;
335
+ if (message) {
336
+ this.emitTraceEvent({
337
+ kind: "message",
338
+ provider: "codex",
339
+ sessionId: this.sessionId,
340
+ role: "assistant",
341
+ content: message,
342
+ eventName: "agent_message",
343
+ model: this.modelId,
344
+ meta: {
345
+ codex_session_id: this.sessionId,
346
+ turn_index: this.turnIndex,
347
+ },
348
+ });
349
+ }
350
+ break;
351
+ }
352
+ }
353
+ }
354
+
355
+ private onResponseItem(payload: Record<string, unknown>): void {
356
+ const itemType = payload.type as string | undefined;
357
+ if (!itemType) return;
358
+
359
+ switch (itemType) {
360
+ case "custom_tool_call":
361
+ this.onToolCall(payload);
362
+ break;
363
+ case "function_call":
364
+ this.onFunctionCall(payload);
365
+ break;
366
+ }
367
+ }
368
+
369
+ private onToolCall(payload: Record<string, unknown>): void {
370
+ const name = payload.name as string | undefined;
371
+ if (name !== "apply_patch") return;
372
+
373
+ const input = payload.input as string | undefined;
374
+ if (!input) return;
375
+
376
+ const parsed = parsePatchInput(input);
377
+ const root = getWorkspaceRoot();
378
+
379
+ for (const [filePath, edits] of parsed) {
380
+ this.emitTraceEvent({
381
+ kind: "file_edit",
382
+ provider: "codex",
383
+ sessionId: this.sessionId,
384
+ filePath: resolve(root, filePath),
385
+ edits,
386
+ model: this.modelId,
387
+ transcript: this.transcriptPath ?? undefined,
388
+ readContent: false,
389
+ eventName: "apply_patch",
390
+ meta: {
391
+ codex_session_id: this.sessionId,
392
+ turn_index: this.turnIndex,
393
+ source: "apply_patch",
394
+ },
395
+ });
396
+ }
397
+ }
398
+
399
+ private onFunctionCall(payload: Record<string, unknown>): void {
400
+ const name = payload.name as string | undefined;
401
+ if (name !== "exec_command") return;
402
+
403
+ let cmd: string | undefined;
404
+ const argsStr = payload.arguments as string | undefined;
405
+ if (argsStr) {
406
+ try {
407
+ const args = JSON.parse(argsStr) as Record<string, unknown>;
408
+ cmd = args.cmd as string | undefined;
409
+ } catch {
410
+ // ignore malformed arguments
411
+ }
412
+ }
413
+
414
+ this.emitTraceEvent({
415
+ kind: "shell",
416
+ provider: "codex",
417
+ sessionId: this.sessionId,
418
+ model: this.modelId,
419
+ transcript: this.transcriptPath ?? undefined,
420
+ meta: {
421
+ codex_session_id: this.sessionId,
422
+ turn_index: this.turnIndex,
423
+ command: cmd,
424
+ },
425
+ });
426
+ }
427
+ }