From 55237dc5cdc153e86e21d79704630b0e50e11a18 Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Tue, 3 Nov 2026 01:22:57 +0000 Subject: [PATCH 1/3] Read task notifications' XML usage block and Monitor events Current Claude Code writes a notification's as child tags (, , ) where older versions wrote "key: value" lines. The parser only read the lines, so for almost every recent background agent and command the card dropped its tokens, tool uses and duration without a sign (609 of 616 usage blocks in a real archive were the tag form). _parse_usage now reads both, mapping either token name to total_tokens and ignoring workflow counters such as agent_count. A Monitor sends one notification per line its command prints, with the line in , no status and a summary that only names the monitor; the event text was not parsed, so the card showed nothing of it. TaskNotificationMessage gains an event field, and the HTML and Markdown cards show it as an Event row (a code block when it spans lines). Sync agent results' metadata tail still uses the key: value form, so agent_metadata_factory is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../factories/task_notification_factory.py | 32 +++-- claude_code_log/html/async_formatter.py | 9 ++ claude_code_log/markdown/renderer.py | 8 ++ claude_code_log/models.py | 7 ++ dev-docs/messages.md | 12 ++ test/test_async_agents.py | 112 ++++++++++++++++++ 6 files changed, 171 insertions(+), 9 deletions(-) diff --git a/claude_code_log/factories/task_notification_factory.py b/claude_code_log/factories/task_notification_factory.py index 1f57b6ff..06df1546 100644 --- a/claude_code_log/factories/task_notification_factory.py +++ b/claude_code_log/factories/task_notification_factory.py @@ -55,8 +55,10 @@ # so the renderer can backlink the notification's Task ID value to the # original tool_use card (#142). Older notifications didn't carry it, # so the absence is benign — the dict get below returns None. +# ``event`` is a Monitor's: one notification per line its command prints, +# carrying the line (no status; the summary just names the monitor). _FIELD_RE = re.compile( - r"<(?Ptask-id|tool-use-id|status|summary)>(?P.*?)", + r"<(?Ptask-id|tool-use-id|status|summary|event)>(?P.*?)", re.DOTALL, ) _RESULT_RE = re.compile(r"\s*(?P.*?)\s*", re.DOTALL) @@ -69,8 +71,13 @@ re.MULTILINE, ) -# Lines inside follow ``key: value`` shape, one per line. +# Inside , older Claude Code wrote ``key: value`` lines; current +# versions write child tags (``N…``). _USAGE_LINE_RE = re.compile(r"^\s*(\w+)\s*:\s*(.+?)\s*$", re.MULTILINE) +_USAGE_TAG_RE = re.compile(r"<(\w+)>\s*([^<]*?)\s*") +# The token count's name in each form. Other keys (a workflow's +# ``agent_count``, ``agents_done``, …) are not usage and are ignored. +_TOKEN_KEYS = ("total_tokens", "subagent_tokens") def has_task_notification(text: str) -> bool: @@ -79,20 +86,26 @@ def has_task_notification(text: str) -> bool: def _parse_usage(body: str) -> TaskNotificationUsage: - """Parse the ``key: value`` lines inside a ```` block. + """Parse a ```` block, in either form. - Unknown keys are ignored; values that don't parse as ``int`` are - skipped (each known field is integer-shaped in practice). + Child tags (current) take the place of ``key: value`` lines (older); + the token count is ``subagent_tokens`` in the one and + ``total_tokens`` in the other. Unknown keys are ignored; values that + don't parse as ``int`` are skipped (each known field is + integer-shaped in practice). """ + pairs = _USAGE_TAG_RE.findall(body) or [ + (m.group(1), m.group(2)) for m in _USAGE_LINE_RE.finditer(body) + ] fields: dict[str, int] = {} - for match in _USAGE_LINE_RE.finditer(body): - key = match.group(1) + for key, value in pairs: try: - fields[key] = int(match.group(2)) + fields[key] = int(value) except ValueError: continue + tokens = next((fields[k] for k in _TOKEN_KEYS if k in fields), None) return TaskNotificationUsage( - total_tokens=fields.get("total_tokens"), + total_tokens=tokens, tool_uses=fields.get("tool_uses"), duration_ms=fields.get("duration_ms"), ) @@ -153,6 +166,7 @@ def create_task_notification_message( task_id=fields.get("task-id", ""), status=fields.get("status", ""), summary=fields.get("summary", ""), + event=fields.get("event", ""), result_text=result_text, usage=usage, transcript_path=transcript_path, diff --git a/claude_code_log/html/async_formatter.py b/claude_code_log/html/async_formatter.py index 180b0538..0b9da87b 100644 --- a/claude_code_log/html/async_formatter.py +++ b/claude_code_log/html/async_formatter.py @@ -35,6 +35,13 @@ def _code(value: str) -> str: return f"{escape_html(value)}" +def _event_html(event: str) -> str: + """A Monitor event's text: inline code for one line, a block for more.""" + if "\n" in event: + return f"
{escape_html(event)}
" + return _code(event) + + def _format_usage_rows(usage: Optional[TaskNotificationUsage]) -> list[str]: """Optional usage stats as `
/
` rows. Skips fields the parser didn't capture so older transcripts (or partial completions) don't @@ -166,6 +173,8 @@ def format_task_notification_content( f"{escape_html(content.status)}", ) ) + if content.event: + rows.append(_row("Event", _event_html(content.event))) rows.extend(_format_usage_rows(content.usage)) if content.transcript_path: rows.append(_row("Transcript", _code(content.transcript_path))) diff --git a/claude_code_log/markdown/renderer.py b/claude_code_log/markdown/renderer.py index 53cef5a0..e81ce4a9 100644 --- a/claude_code_log/markdown/renderer.py +++ b/claude_code_log/markdown/renderer.py @@ -1611,6 +1611,12 @@ def format_TaskNotificationMessage( lines.append(f"- **Task ID:** `{content.task_id}`") if content.status: lines.append(f"- **Status:** `{content.status}`") + event_block = "" + if content.event and "\n" in content.event: + lines.append("- **Event:**") + event_block = self._code_fence(content.event) + elif content.event: + lines.append(f"- **Event:** {_inline_code(content.event)}") usage = content.usage if usage is not None: if usage.total_tokens is not None: @@ -1626,6 +1632,8 @@ def format_TaskNotificationMessage( f"- **Spawn:** ↱ Task `#d-{content.spawning_task_message_index}`" ) head = "\n".join(lines) + if event_block: + head = f"{head}\n\n{event_block}" if content.result_text and not content.result_is_duplicate: body = self._collapsible("Result", content.result_text) return f"{head}\n\n{body}" if head else body diff --git a/claude_code_log/models.py b/claude_code_log/models.py index cb00175f..815c1e67 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -1066,6 +1066,10 @@ class TaskNotificationMessage(MessageContent): Full transcript available at: /tmp/.../tasks/a8b740b.output + (Current Claude Code writes ```` as child tags instead — + ``……`` — and a Monitor's + notifications carry an ````; see ``task_notification_factory``.) + The ``result`` body usually duplicates the last sub-assistant message in the spawned agent's sidechain — Phase 3 of the async-agents plan folds it into the spawning Task's tool_result so @@ -1075,6 +1079,9 @@ class TaskNotificationMessage(MessageContent): task_id: str = "" status: str = "" summary: str = "" + # A Monitor event's text (the line its command printed); empty for + # any other notification. + event: str = "" result_text: str = "" usage: Optional[TaskNotificationUsage] = None transcript_path: Optional[str] = None diff --git a/dev-docs/messages.md b/dev-docs/messages.md index 9657318b..ac23c63a 100644 --- a/dev-docs/messages.md +++ b/dev-docs/messages.md @@ -371,6 +371,7 @@ class TaskNotificationMessage(MessageContent): task_id: str status: str # "completed", "failed", … summary: str # Agent "" completed + event: str # a Monitor event's text ("" otherwise) result_text: str # the agent's final answer body usage: Optional[TaskNotificationUsage] transcript_path: Optional[str] # "Full transcript available at: …" @@ -380,6 +381,17 @@ class TaskNotificationMessage(MessageContent): spawning_task_message_index: Optional[int] = None ``` +`` comes in two forms, and `_parse_usage` reads both: older +Claude Code wrote `key: value` lines (`total_tokens: N` …), current +versions write child tags (`N +NN`; a few carry +`` instead, and a workflow adds counters such as +``, which are ignored). Both token names land in +`TaskNotificationUsage.total_tokens`. A Monitor sends one notification +per line its command prints, with no status, the line in `` and a +summary that only names the monitor; the card shows that text as an +*Event* row (a code block when it spans lines). + The `result_text` is the canonical source for the spawn-fold — `_link_async_notifications` copies it onto `TaskOutput.async_final_answer` of the spawning `Task` tool_result so diff --git a/test/test_async_agents.py b/test/test_async_agents.py index 1fbd2366..afcff7d3 100644 --- a/test/test_async_agents.py +++ b/test/test_async_agents.py @@ -215,6 +215,118 @@ def test_partial_usage_block_keeps_known_fields(self) -> None: ) +# --------------------------------------------------------------------------- +# in its XML form, and monitor s +# --------------------------------------------------------------------------- + + +def _notification(inner: str) -> str: + return f"\nt1\n{inner}\n" + + +class TestNotificationUsageAndEvents: + """Current Claude Code writes ```` as child tags, not the older + ``key: value`` lines; and a Monitor's events carry their text in + ````. Both were dropped silently before.""" + + def test_xml_usage_with_subagent_tokens(self) -> None: + msg = create_task_notification_message( + _meta(), + _notification( + "completed\n" + "104863" + "30351871" + ), + ) + assert msg is not None + assert msg.usage == TaskNotificationUsage( + total_tokens=104863, tool_uses=30, duration_ms=351871 + ) + + def test_xml_usage_with_total_tokens_and_extra_counters(self) -> None: + msg = create_task_notification_message( + _meta(), + _notification( + "5003" + "12004" + "4" + ), + ) + assert msg is not None + assert msg.usage == TaskNotificationUsage( + total_tokens=500, tool_uses=3, duration_ms=1200 + ) + + def test_legacy_usage_lines_still_parse(self) -> None: + msg = create_task_notification_message( + _meta(), + _notification( + "total_tokens: 7\ntool_uses: 1\nduration_ms: 9" + ), + ) + assert msg is not None + assert msg.usage == TaskNotificationUsage( + total_tokens=7, tool_uses=1, duration_ms=9 + ) + + def test_monitor_event_text_is_kept(self) -> None: + msg = create_task_notification_message( + _meta(), + _notification( + 'Monitor event: "CI run"\n' + "step 3 passed" + ), + ) + assert msg is not None + assert msg.event == "step 3 passed" + assert msg.summary == 'Monitor event: "CI run"' + + def test_html_card_shows_usage_and_event(self) -> None: + from claude_code_log.html.async_formatter import ( + format_task_notification_content, + ) + + one_line = create_task_notification_message( + _meta(), + _notification( + "exit 1\n" + "1234" + ), + ) + assert one_line is not None + html = format_task_notification_content(one_line) + assert "
Event
exit <b>1</b>
" in html + assert "
Tokens
1,234
" in html + + several = create_task_notification_message( + _meta(), _notification("line one\nline two") + ) + assert several is not None + assert "
line one\nline two
" in ( + format_task_notification_content(several) + ) + + def test_markdown_card_shows_the_event(self) -> None: + from typing import Any, cast + + from claude_code_log.markdown.renderer import MarkdownRenderer + + renderer = MarkdownRenderer() + one_line = create_task_notification_message( + _meta(), _notification("step 3 passed") + ) + several = create_task_notification_message( + _meta(), _notification("line one\nline two") + ) + assert one_line is not None and several is not None + assert ( + "- **Event:** `step 3 passed`" + in renderer.format_TaskNotificationMessage(one_line, cast(Any, None)) + ) + md = renderer.format_TaskNotificationMessage(several, cast(Any, None)) + assert "line one\nline two" in md and "```" in md + + # --------------------------------------------------------------------------- # parse_taskoutput_output # --------------------------------------------------------------------------- From 3f148dfedffd664e0dc03acc883c388a542d6476 Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Tue, 3 Nov 2026 01:45:54 +0000 Subject: [PATCH 2/3] Take a notification's free-text blocks out before parsing the rest Review feedback: and were searched for on the whole notification body, so a Monitor event quoting either tag (it is whatever the monitored command printed) had the quoted block read as the real one, and stripped from the event text. (an agent's Markdown) and (a command's output) are both free text that can quote any tag, so both are now taken out first, each where it first opens, and only the rest is searched for and the header fields. A result quoting and an event quoting are covered too. Co-Authored-By: Claude Opus 5.5 --- .../factories/task_notification_factory.py | 55 ++++++++++++------- dev-docs/messages.md | 5 +- test/test_async_agents.py | 48 ++++++++++++++++ 3 files changed, 86 insertions(+), 22 deletions(-) diff --git a/claude_code_log/factories/task_notification_factory.py b/claude_code_log/factories/task_notification_factory.py index 06df1546..61ab4c2f 100644 --- a/claude_code_log/factories/task_notification_factory.py +++ b/claude_code_log/factories/task_notification_factory.py @@ -55,13 +55,16 @@ # so the renderer can backlink the notification's Task ID value to the # original tool_use card (#142). Older notifications didn't carry it, # so the absence is benign — the dict get below returns None. -# ``event`` is a Monitor's: one notification per line its command prints, -# carrying the line (no status; the summary just names the monitor). _FIELD_RE = re.compile( - r"<(?Ptask-id|tool-use-id|status|summary|event)>(?P.*?)", + r"<(?Ptask-id|tool-use-id|status|summary)>(?P.*?)", re.DOTALL, ) -_RESULT_RE = re.compile(r"\s*(?P.*?)\s*", re.DOTALL) +# The free-text blocks: an agent's ```` (its Markdown answer) and a +# Monitor's ```` (one notification per line its command prints; no +# status, and the summary only names the monitor). Either can quote any tag. +_FREE_TEXT_RE = re.compile( + r"<(?Presult|event)>\s*(?P.*?)\s*", re.DOTALL +) _USAGE_RE = re.compile(r"\s*(?P.*?)\s*", re.DOTALL) # ``Full transcript available at: `` trailer outside the @@ -111,6 +114,17 @@ def _parse_usage(body: str) -> TaskNotificationUsage: ) +def _take_free_text(body: str) -> tuple[dict[str, str], str]: + """``({tag: text}, rest)``: the ```` and ```` blocks, + each taken out where it first opens, and the body without them.""" + taken: dict[str, str] = {} + rest = body + while (match := _FREE_TEXT_RE.search(rest)) and match.group("tag") not in taken: + taken[match.group("tag")] = match.group("body") + rest = rest[: match.start()] + rest[match.end() :] + return taken, rest + + def create_task_notification_message( meta: MessageMeta, text: str ) -> Optional[TaskNotificationMessage]: @@ -128,24 +142,23 @@ def create_task_notification_message( body = notification.group("body") - # Extract ```` and ```` first, then strip their full - # match text from the search surface before scanning for the - # single-tag header fields. ```` bodies are agent-authored - # markdown and frequently contain literal HTML/XML — a - # ``demo`` snippet inside ```` would - # otherwise clobber the real notification ```` field - # (and similarly for ```` / ````), poisoning the - # downstream fold/dedup path. - result_match = _RESULT_RE.search(body) - result_text = result_match.group("body") if result_match else "" - - usage_match = _USAGE_RE.search(body) + # Take the free-text blocks out first, then search what is left for + # ```` and the single-tag header fields. ```` bodies are + # agent-authored markdown and frequently contain literal HTML/XML, and + # an ```` is whatever the monitored command printed — a + # ``demo`` (or a ````) inside either would + # otherwise clobber the real field, poisoning the downstream + # fold/dedup path. Whichever block opens first is the real one; a + # second tag of the same kind is part of its text, never a field. + free_text, rest = _take_free_text(body) + result_text = free_text.get("result", "") + + usage_match = _USAGE_RE.search(rest) usage = _parse_usage(usage_match.group("body")) if usage_match else None - header_body = body - for block_match in (result_match, usage_match): - if block_match is not None: - header_body = header_body.replace(block_match.group(0), "", 1) + header_body = rest + if usage_match is not None: + header_body = header_body.replace(usage_match.group(0), "", 1) fields: dict[str, str] = {} for match in _FIELD_RE.finditer(header_body): @@ -166,7 +179,7 @@ def create_task_notification_message( task_id=fields.get("task-id", ""), status=fields.get("status", ""), summary=fields.get("summary", ""), - event=fields.get("event", ""), + event=free_text.get("event", ""), result_text=result_text, usage=usage, transcript_path=transcript_path, diff --git a/dev-docs/messages.md b/dev-docs/messages.md index ac23c63a..faa9ed8e 100644 --- a/dev-docs/messages.md +++ b/dev-docs/messages.md @@ -390,7 +390,10 @@ versions write child tags (`N `TaskNotificationUsage.total_tokens`. A Monitor sends one notification per line its command prints, with no status, the line in `` and a summary that only names the monitor; the card shows that text as an -*Event* row (a code block when it spans lines). +*Event* row (a code block when it spans lines). `` and `` are free +text (a command's output, an agent's Markdown) and can quote any tag, so +the parser takes them out first, each where it first opens, and only then +searches the rest for `` and the header fields. The `result_text` is the canonical source for the spawn-fold — `_link_async_notifications` copies it onto diff --git a/test/test_async_agents.py b/test/test_async_agents.py index afcff7d3..486224e9 100644 --- a/test/test_async_agents.py +++ b/test/test_async_agents.py @@ -281,6 +281,54 @@ def test_monitor_event_text_is_kept(self) -> None: assert msg.event == "step 3 passed" assert msg.summary == 'Monitor event: "CI run"' + def test_an_event_quoting_result_or_usage_tags_stays_intact(self) -> None: + """An event is whatever the monitored command printed: tags in it + are its text, not the notification's own blocks.""" + printed = "log: ok 9" + msg = create_task_notification_message( + _meta(), + _notification( + f'Monitor event: "log"\n{printed}' + ), + ) + assert msg is not None + assert msg.event == printed + assert msg.result_text == "" + assert msg.usage is None + + def test_an_event_quoting_header_tags_does_not_set_them(self) -> None: + msg = create_task_notification_message( + _meta(), + _notification("saw failed in the log"), + ) + assert msg is not None + assert msg.status == "" + assert msg.event == "saw failed in the log" + + def test_a_result_quoting_an_event_tag_is_not_an_event(self) -> None: + msg = create_task_notification_message( + _meta(), + _notification( + "completed\n" + "Monitors print a line each." + ), + ) + assert msg is not None + assert msg.event == "" + assert msg.result_text == "Monitors print a line each." + + def test_a_real_result_and_usage_beside_an_event_still_parse(self) -> None: + msg = create_task_notification_message( + _meta(), + _notification( + "line\nDone.\n" + "5" + ), + ) + assert msg is not None + assert (msg.event, msg.result_text) == ("line", "Done.") + assert msg.usage is not None and msg.usage.total_tokens == 5 + def test_html_card_shows_usage_and_event(self) -> None: from claude_code_log.html.async_formatter import ( format_task_notification_content, From 71d8d5b6dffb2b66e1c46dcf245a37d067705370 Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Tue, 3 Nov 2026 01:54:41 +0000 Subject: [PATCH 3/3] Notification free text: run to the last closing tag; keep events verbatim Review feedback on the free-text extraction: - An event (or result) quoting its own tag ended at the quoted closing tag, leaving the rest of its text to be read as fields. A notification has at most one of each block, so a block now runs to the last closing tag of its kind. - An event is the monitored command's output; trimming it lost indentation and trailing spaces. Events are kept verbatim (real ones are written inline, never padded with newlines); results stay trimmed. Co-Authored-By: Claude Opus 5.5 --- .../factories/task_notification_factory.py | 19 ++++++++---- dev-docs/messages.md | 7 +++-- test/test_async_agents.py | 30 +++++++++++++++++++ 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/claude_code_log/factories/task_notification_factory.py b/claude_code_log/factories/task_notification_factory.py index 61ab4c2f..4a0c9ca4 100644 --- a/claude_code_log/factories/task_notification_factory.py +++ b/claude_code_log/factories/task_notification_factory.py @@ -61,10 +61,10 @@ ) # The free-text blocks: an agent's ```` (its Markdown answer) and a # Monitor's ```` (one notification per line its command prints; no -# status, and the summary only names the monitor). Either can quote any tag. -_FREE_TEXT_RE = re.compile( - r"<(?Presult|event)>\s*(?P.*?)\s*", re.DOTALL -) +# status, and the summary only names the monitor). Either can quote any +# tag, its own included, so a block runs to the *last* closing tag of its +# kind (greedy): a notification has one of each at most. +_FREE_TEXT_RE = re.compile(r"<(?Presult|event)>(?P.*)", re.DOTALL) _USAGE_RE = re.compile(r"\s*(?P.*?)\s*", re.DOTALL) # ``Full transcript available at: `` trailer outside the @@ -116,11 +116,18 @@ def _parse_usage(body: str) -> TaskNotificationUsage: def _take_free_text(body: str) -> tuple[dict[str, str], str]: """``({tag: text}, rest)``: the ```` and ```` blocks, - each taken out where it first opens, and the body without them.""" + each taken out where it first opens, and the body without them. + + A result is Markdown, trimmed; an event is the command's output, kept + verbatim (its indentation is part of it). + """ taken: dict[str, str] = {} rest = body while (match := _FREE_TEXT_RE.search(rest)) and match.group("tag") not in taken: - taken[match.group("tag")] = match.group("body") + text = match.group("body") + taken[match.group("tag")] = ( + text.strip() if match.group("tag") == "result" else text + ) rest = rest[: match.start()] + rest[match.end() :] return taken, rest diff --git a/dev-docs/messages.md b/dev-docs/messages.md index faa9ed8e..a4e302c0 100644 --- a/dev-docs/messages.md +++ b/dev-docs/messages.md @@ -392,8 +392,11 @@ per line its command prints, with no status, the line in `` and a summary that only names the monitor; the card shows that text as an *Event* row (a code block when it spans lines). `` and `` are free text (a command's output, an agent's Markdown) and can quote any tag, so -the parser takes them out first, each where it first opens, and only then -searches the rest for `` and the header fields. +the parser takes them out first, each where it first opens and running +to the last closing tag of its kind (a notification has one of each, so +a quoted inner tag is text), and only then searches the rest for +`` and the header fields. A result is trimmed; an event is kept +verbatim, indentation included. The `result_text` is the canonical source for the spawn-fold — `_link_async_notifications` copies it onto diff --git a/test/test_async_agents.py b/test/test_async_agents.py index 486224e9..51056f7b 100644 --- a/test/test_async_agents.py +++ b/test/test_async_agents.py @@ -317,6 +317,36 @@ def test_a_result_quoting_an_event_tag_is_not_an_event(self) -> None: assert msg.event == "" assert msg.result_text == "Monitors print a line each." + def test_an_event_quoting_its_own_tag_stays_whole(self) -> None: + """The real block is the only one of its kind, so it closes at the + last closing tag: a quoted inner one is text.""" + printed = "a x then failed" + msg = create_task_notification_message( + _meta(), + _notification(f"completed\n{printed}"), + ) + assert msg is not None + assert msg.event == printed + assert msg.status == "completed" + + def test_a_result_quoting_its_own_closing_tag_stays_whole(self) -> None: + answer = "Wrap it as … and you're done." + msg = create_task_notification_message( + _meta(), + _notification(f"completed\n{answer}"), + ) + assert msg is not None + assert msg.result_text == answer + + def test_an_event_keeps_its_whitespace(self) -> None: + """An event is the command's output, verbatim: indentation and + trailing spaces are part of it.""" + msg = create_task_notification_message( + _meta(), _notification(" indented line ") + ) + assert msg is not None + assert msg.event == " indented line " + def test_a_real_result_and_usage_beside_an_event_still_parse(self) -> None: msg = create_task_notification_message( _meta(),