diff --git a/internal/transcript/codex.go b/internal/transcript/codex.go
index 528b1b3..18670fb 100644
--- a/internal/transcript/codex.go
+++ b/internal/transcript/codex.go
@@ -32,11 +32,65 @@ func isCodexUserPrompt(e map[string]any) bool {
case "event_msg":
return getStr(p, "type") == "user_message" && pyStrip(getStr(p, "message")) != ""
case "response_item":
- return getStr(p, "type") == "message" && getStr(p, "role") == "user"
+ if getStr(p, "type") != "message" || getStr(p, "role") != "user" {
+ return false
+ }
+ // Text that is nothing but Codex's own injected context is not a
+ // prompt; an image-only message (no text at all) still is.
+ if text := codexUserText(p); text != "" && stripCodexInjected(text) == "" {
+ return false
+ }
+ return true
}
return false
}
+// codexUserText joins the text parts of a role=user response_item payload,
+// whichever shape Codex used for content (a string or a list of parts).
+func codexUserText(p map[string]any) string {
+ switch c := p["content"].(type) {
+ case string:
+ return c
+ case []any:
+ var b strings.Builder
+ for _, x := range c {
+ bm, ok := x.(map[string]any)
+ if !ok {
+ continue
+ }
+ switch getStr(bm, "type") {
+ case "input_text", "text", "output_text":
+ b.WriteString(getStr(bm, "text"))
+ }
+ }
+ return b.String()
+ }
+ return ""
+}
+
+// Codex opens a thread, and re-opens it after a cwd change, by injecting its
+// own context as role=user response_items: the AGENTS.md chain wrapped in
+// (older builds: ), usually with an
+// block of cwd/shell/date/permissions appended, and a
+// bare later on. Nobody typed those. Captured as
+// prompts they open every Codex log with a ~10 KB copy of AGENTS.md, which a
+// brain fold then reads as owner instructions and re-learns in every
+// project. Anchored at the start of the text and closed by the end tag, so a
+// prompt that merely mentions a tag, or an unclosed block, is left alone.
+var codexInjectedRe = regexp.MustCompile(`(?s)\A\s*(?:# AGENTS\.md instructions.*?|.*?|.*?)\s*`)
+
+// stripCodexInjected removes every leading injected block from a user
+// message's text. A message that is only injected context strips to "".
+func stripCodexInjected(text string) string {
+ for {
+ loc := codexInjectedRe.FindStringIndex(text)
+ if loc == nil || loc[1] == 0 {
+ return text
+ }
+ text = text[loc[1]:]
+ }
+}
+
// codexPromptText is _codex_prompt_text: the stripped user prompt carried by
// an event, or "".
func codexPromptText(e map[string]any) string {
@@ -50,23 +104,7 @@ func codexPromptText(e map[string]any) string {
if getStr(p, "type") != "message" || getStr(p, "role") != "user" {
return ""
}
- switch c := p["content"].(type) {
- case string:
- return pyStrip(c)
- case []any:
- var b strings.Builder
- for _, x := range c {
- bm, ok := x.(map[string]any)
- if !ok {
- continue
- }
- switch getStr(bm, "type") {
- case "input_text", "text", "output_text":
- b.WriteString(getStr(bm, "text"))
- }
- }
- return pyStrip(b.String())
- }
+ return pyStrip(stripCodexInjected(codexUserText(p)))
}
return ""
}
diff --git a/internal/transcript/codex_injected_test.go b/internal/transcript/codex_injected_test.go
new file mode 100644
index 0000000..14d4d9f
--- /dev/null
+++ b/internal/transcript/codex_injected_test.go
@@ -0,0 +1,65 @@
+package transcript
+
+// Codex opens a thread by injecting its own context as role=user
+// response_items: the AGENTS.md chain wrapped in (with an
+// block appended) and, after a cwd change, a bare
+// . Nobody typed those. Captured as prompts they open
+// every Codex log with a ~10 KB copy of AGENTS.md, which a brain fold then
+// reads as owner instructions and re-learns in every project.
+
+import (
+ "strings"
+ "testing"
+)
+
+const codexInjected = `{"type":"session_meta","payload":{"cwd":"/codex/inj"}}
+{"type":"turn_context","payload":{"collaboration_mode":{"settings":{"model":"gpt-5.3"}}}}
+{"type":"response_item","timestamp":"2026-08-01T00:00:00Z","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"# AGENTS.md instructions for /codex/inj\n\n\n## House rules\n\n- Never push to main.\n\n\n /codex/inj\n zsh\n"}]}}
+{"type":"response_item","timestamp":"2026-08-01T00:00:01Z","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"fix the flaky test"}]}}
+{"type":"response_item","timestamp":"2026-08-01T00:00:05Z","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Pinned the clock."}]}}
+{"type":"event_msg","timestamp":"2026-08-01T00:00:06Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5}}}}
+{"type":"response_item","timestamp":"2026-08-01T00:01:00Z","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"\n /codex/inj/sub\n"}]}}
+{"type":"response_item","timestamp":"2026-08-01T00:01:01Z","payload":{"type":"message","role":"user","content":"second task"}}
+{"type":"response_item","timestamp":"2026-08-01T00:01:05Z","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Done."}]}}
+{"type":"event_msg","timestamp":"2026-08-01T00:01:06Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":20,"cached_input_tokens":0,"output_tokens":3}}}}
+`
+
+func TestCodexInjectedContextIsNotAPrompt(t *testing.T) {
+ t.Parallel()
+ path := writeFixture(t, "codex-injected.jsonl", codexInjected)
+ want := []string{
+ `dt=2026-08-01T00:00:01Z|cwd=/codex/inj|prompt="fix the flaky test"|texts=["Pinned the clock."]|tools=|files=|turn_ts=2026-08-01T00:00:06Z|tok=10/5/0/0|model=gpt-5.3`,
+ `dt=2026-08-01T00:01:01Z|cwd=/codex/inj|prompt="second task"|texts=["Done."]|tools=|files=|turn_ts=2026-08-01T00:01:06Z|tok=20/3/0/0|model=gpt-5.3`,
+ }
+ // Injected context is never a prompt, whichever way synthetic prompts
+ // (the markers) are treated.
+ checkTurns(t, Turns(path, 0, true), want)
+ checkTurns(t, Turns(path, 0, false), want)
+ for _, turn := range Turns(path, 0, false) {
+ if strings.Contains(turn.Prompt, "AGENTS.md") || strings.Contains(turn.Prompt, "environment_context") {
+ t.Errorf("Codex-injected context leaked into a prompt: %q", turn.Prompt)
+ }
+ }
+}
+
+func TestStripCodexInjected(t *testing.T) {
+ t.Parallel()
+ cases := []struct{ name, in, want string }{
+ {"instructions-only", "# AGENTS.md instructions for /x\n\n\n- rule\n", ""},
+ {"instructions-then-environment", "# AGENTS.md instructions\n\n\n- rule\n\n\n /x\n", ""},
+ {"environment-only", "\n /x\n", ""},
+ {"legacy-user-instructions", "\nbe terse\n", ""},
+ {"environment-then-typed-text", "\n /x\n\n\nfix the flaky test", "fix the flaky test"},
+ {"typed-text-untouched", "fix the flaky test", "fix the flaky test"},
+ {"prose-mention-untouched", "why does appear in my logs?", "why does appear in my logs?"},
+ {"unclosed-block-untouched", "\n /x", "\n /x"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ t.Parallel()
+ if got := stripCodexInjected(c.in); got != c.want {
+ t.Errorf("stripCodexInjected(%q) = %q, want %q", c.in, got, c.want)
+ }
+ })
+ }
+}