diff --git a/__tests__/news-regressions.test.mjs b/__tests__/news-regressions.test.mjs
index 4975047..b3eefdb 100644
--- a/__tests__/news-regressions.test.mjs
+++ b/__tests__/news-regressions.test.mjs
@@ -1,6 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
+import { spawnSync } from 'node:child_process'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
@@ -17,6 +18,61 @@ const HERE = dirname(fileURLToPath(import.meta.url))
const repo = join(HERE, '..')
const readRepoFile = (name) => readFileSync(join(repo, name), 'utf8')
+const decodeCodexOutput = (input) => {
+ const result = spawnSync('python3', [join(repo, 'codex_output.py')], {
+ input,
+ encoding: 'utf8',
+ })
+ assert.equal(result.status, 0, result.stderr)
+ return result.stdout
+}
+
+test('Codex output decoder unwraps current item.completed messages', () => {
+ const html = '\nToday
\n'
+ const jsonl = [
+ JSON.stringify({ type: 'thread.started', thread_id: 'thread-1' }),
+ JSON.stringify({
+ type: 'item.completed',
+ item: { type: 'agent_message', text: html },
+ }),
+ JSON.stringify({ type: 'turn.completed', usage: { input_tokens: 10 } }),
+ ].join('\n')
+
+ assert.equal(decodeCodexOutput(jsonl), html)
+ assert.equal(decodeCodexOutput(jsonl).includes('\\n'), false,
+ 'JSON escapes must become real newlines before HTML extraction')
+})
+
+test('Codex output decoder preserves legacy agent-message envelopes', () => {
+ const first = JSON.stringify({ type: 'agent_message', message: '' })
+ const second = JSON.stringify({ msg: { type: 'agent_message', message: '' } })
+ assert.equal(decodeCodexOutput(`${first}\n${second}\n`), '')
+})
+
+test('Codex output decoder fails closed for unknown JSON transport', () => {
+ // This is the regression guard for the visible "\\n" failure: markup in an
+ // unrecognized transport event must not be handed to the HTML scanner raw.
+ const escapedReport = '\\ntransport, not content
\\n'
+ const jsonl = JSON.stringify({
+ type: 'item.completed',
+ item: { type: 'reasoning', text: escapedReport },
+ })
+ assert.equal(decodeCodexOutput(jsonl), '')
+})
+
+test('Codex output decoder keeps plain-text compatibility without JSON transport', () => {
+ const html = '\nlegacy plain output
\n'
+ assert.equal(decodeCodexOutput(html), html)
+})
+
+test('fetch.sh decodes Codex transport before scanning for report HTML', () => {
+ const sh = readRepoFile('fetch.sh')
+ assert.ok(sh.includes('from codex_output import extract_codex_agent_text'))
+ assert.ok(sh.includes('text = extract_codex_agent_text(raw)'))
+ assert.ok(!sh.includes('msg = obj.get("msg", obj)'),
+ 'the obsolete envelope-specific inline parser must not return')
+})
+
// --- Blocker 1: "Generate report now" must terminate on a run-status terminal,
// even when a preserved good digest leaves reports/.html (and thus its
// mtime) untouched. These EXECUTE the extracted terminal-detection decision;
diff --git a/codex_output.py b/codex_output.py
new file mode 100644
index 0000000..efde8db
--- /dev/null
+++ b/codex_output.py
@@ -0,0 +1,107 @@
+#!/usr/bin/env python3
+"""Extract assistant text from ``codex exec --json`` output.
+
+Codex writes JSONL transport events, not the assistant message verbatim. Keep
+that transport boundary explicit: known agent-message envelopes are decoded,
+plain non-JSON output remains a legacy fallback, and valid-but-unknown JSONL
+fails closed instead of being mistaken for report HTML.
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from typing import Any
+
+
+_AGENT_MESSAGE_TYPES = {"agent_message", "agentMessage"}
+_COMPLETED_EVENT_TYPES = {"item.completed", "item_completed"}
+
+
+def _content_text(value: Any) -> str:
+ """Normalize string or text-block content without accepting tool output."""
+ if isinstance(value, str):
+ return value
+ if not isinstance(value, list):
+ return ""
+
+ parts: list[str] = []
+ for block in value:
+ if not isinstance(block, dict):
+ continue
+ if block.get("type") not in ("text", "output_text"):
+ continue
+ text = block.get("text")
+ if isinstance(text, str):
+ parts.append(text)
+ return "".join(parts)
+
+
+def _message_text(item: Any) -> str:
+ if not isinstance(item, dict) or item.get("type") not in _AGENT_MESSAGE_TYPES:
+ return ""
+ for key in ("text", "content", "message"):
+ text = _content_text(item.get(key))
+ if text:
+ return text
+ return ""
+
+
+def extract_codex_agent_text(raw: str) -> str:
+ """Return decoded assistant text, or ``""`` for unknown JSONL events.
+
+ Supported envelopes:
+ * current: ``item.completed`` with ``item.type=agent_message`` + ``text``
+ * legacy: a top-level ``agent_message`` event
+ * legacy: an ``agent_message`` nested under ``msg``
+
+ Multiple completed message items are joined in stream order. If there are
+ no parseable JSON objects at all, ``raw`` is returned for compatibility
+ with old/plain-text Codex output. Once JSON transport is detected, however,
+ raw fallback is unsafe: escaped HTML inside an unknown event must never be
+ scanned as if it were the report body.
+ """
+ parts: list[str] = []
+ saw_json_transport = False
+
+ for raw_line in raw.splitlines():
+ line = raw_line.strip()
+ if not line:
+ continue
+ try:
+ event = json.loads(line)
+ except (TypeError, ValueError):
+ continue
+ if not isinstance(event, dict):
+ continue
+
+ saw_json_transport = True
+ event_type = event.get("type")
+ candidates: list[Any] = []
+ if event_type in _COMPLETED_EVENT_TYPES:
+ candidates.append(event.get("item"))
+ elif event_type in _AGENT_MESSAGE_TYPES:
+ candidates.append(event)
+
+ # Older Codex builds wrapped the semantic event in ``msg``.
+ msg = event.get("msg")
+ if isinstance(msg, dict) and msg.get("type") in _AGENT_MESSAGE_TYPES:
+ candidates.append(msg)
+
+ for candidate in candidates:
+ text = _message_text(candidate)
+ if text:
+ parts.append(text)
+
+ if parts:
+ return "".join(parts)
+ return "" if saw_json_transport else raw
+
+
+def main() -> int:
+ sys.stdout.write(extract_codex_agent_text(sys.stdin.read()))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/fetch.sh b/fetch.sh
index 4af1053..8a8dc72 100755
--- a/fetch.sh
+++ b/fetch.sh
@@ -40,6 +40,8 @@
set -uo pipefail
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+
APP_ID="${1:-}"
if [ -z "$APP_ID" ]; then
echo "fetch.sh: APP_ID required as first argument" >&2
@@ -890,48 +892,31 @@ fi
# 4. Extract the HTML report article from the agent's output.
# - Claude -p: stdout is the final assistant message text verbatim.
-# - Codex exec --json: stdout is JSONL; the final `agent_message`
-# event carries the text. python3 grabs the last `agent_message`
-# payload, or falls back to the raw bytes if parsing fails.
+# - Codex exec --json: stdout is JSONL. codex_output.py decodes current
+# and legacy agent-message envelopes. Unknown JSON transport fails closed
+# instead of letting escaped HTML inside the envelope pass as a report.
# The agent is told to reply with bare HTML, but we tolerate
# surrounding prose by scanning for the first block. Then
# we sanitize server-side: scripts/styles/event handlers are removed,
# only a small article-writing tag set is kept, and anchors keep only
# http(s) hrefs with safe target/rel attributes.
EXTRACTED_FILE="$WORK_DIR/extracted.html"
-python3 - "$RAW_OUTPUT" "$EXTRACTED_FILE" "$PROVIDER" "$TODAY" <<'PY' 2>>"$LOG_FILE"
+python3 - "$RAW_OUTPUT" "$EXTRACTED_FILE" "$PROVIDER" "$TODAY" "$SCRIPT_DIR" <<'PY' 2>>"$LOG_FILE"
from html import escape
from html.parser import HTMLParser
import json
import re
import sys
-raw_path, out_path, provider, today = sys.argv[1:5]
+raw_path, out_path, provider, today, script_dir = sys.argv[1:6]
with open(raw_path, "r", encoding="utf-8", errors="replace") as f:
raw = f.read()
text = raw
if provider == "codex":
- # Last `agent_message` event holds the final text. Fall back to raw
- # if no parseable lines (older codex shapes, mid-stream truncation).
- last = ""
- for line in raw.splitlines():
- line = line.strip()
- if not line:
- continue
- try:
- obj = json.loads(line)
- except json.JSONDecodeError:
- continue
- # Codex shape: {"type": "agent_message", "message": "..."} OR
- # {"msg": {"type": "agent_message", "message": "..."}}.
- msg = obj.get("msg", obj)
- if isinstance(msg, dict) and msg.get("type") == "agent_message":
- m = msg.get("message", "")
- if isinstance(m, str):
- last = m
- if last:
- text = last
+ sys.path.insert(0, script_dir)
+ from codex_output import extract_codex_agent_text
+ text = extract_codex_agent_text(raw)
match = re.search(r"", text, re.I)
if not match: