Cuts the promoted range's bullets from [Unreleased] into a stamped
## [v0.52.28] section (41 bullets, v0.52.0..v0.52.28). Companion to the
v0.52.28 stable tag pushed by the promotion engine.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Measure-only TTFT (ttft_ms) on the metering session + _firstTokenMs diagnostic. Computed once
on first token (no hot-path latency), all mutation under existing GlobalMeter._lock, existing
token/usage/cost metering unchanged. Codex SAFE (16-stream concurrency probe, no leak/None-deref)
+ stream-gate GREEN all modes + 22 tests.
Local (non-gateway) _resolve_approval_legacy collapsed 'once'+'session' into approve_session()
→ 'approve once' silently persisted session-wide. Narrowed to choice=='session' only; 'once'
still resolves the current call but doesn't persist. Codex SAFE (adversarial: once resolves
current+no residue+next re-prompts, session/always/deny unregressed, gateway consistent, tests
non-vacuous + parked-thread probe of all 4 choices).
In the local (non-gateway) approval path, _resolve_approval_legacy grouped
"once" together with "session" and called approve_session() for both.
approve_session() records the pattern in the session-wide allowlist that the
guard later consults, so a single "Approve once" click silently approved every
later matching tool call for the whole session -- a destructive tool could then
run with no approval card.
Only "session"/"always" persist now. "once" falls through: the
resolve_gateway_approval() call below still unblocks the parked agent thread for
every choice, so the current tool call proceeds, but nothing is written to
_session_approved and the next matching guarded call re-prompts.
Adds tests/test_issue6017_approve_once_no_persist.py, asserting is_approved is
False after "once" and True after "session".
Fail-soft git worktree unlock before remove (agent locks worktrees it creates; git refuses
--force remove on locked → orphan accumulation). Dirty-guard still runs first. Codex SAFE,
13 tests pass. Shipped solo (unbundled from #6013 which has a full-suite test-interaction).
WebUI-created worktrees are locked at creation by the agent
(cli._setup_worktree locks with --reason 'hermes pid=...'), but
remove_worktree_for_session never unlocked, so git refused every removal
with "use 'remove -f -f' to override or unlock first" — force=true
included, since a single --force cannot override a lock.
Mirror the agent's own _cleanup_worktree ordering: fail-soft
'git worktree unlock' right before the remove, after all
dirty/untracked/unpushed/stream/terminal guards have passed. Those
guards remain the real safety layer; force keeps meaning 'override
dirty working state', never 'override a foreign lock'.
Tests: locked worktree now removes cleanly without force; unlock is
fail-soft when the worktree was never locked; existing mocked
call-order assertions updated for the leading unlock call.
Fixes#6023
MEDIA tokens (image/video/audio/pdf/diff/csv/excalidraw) render inline in real time
during streaming incl split-across-chunk markers. Codex SAFE r4 (both prose regressions
closed — ownership preserved + fade retained, normal prose byte-identical to master, all
4 original findings fixed, security clean, no ReDoS), stream-gate GREEN all modes (hide_all
RED was load flake, isolated re-run clean). 227 tests + 18 real-SMD subtests pass. 4-round
convergence on the crown-jewel render + security path.
Greptile re-review (3/5) flagged two issues:
1. parserFor fallback ('__default__') string key → WeakMap.set throws
TypeError (WeakMap accepts only object keys). Fixed by introducing a
module-level sentinel object __SMD_PARSER_FALLBACK = {} and using it
in both parserFor definitions instead of the string literal.
2. _smdMediaTailClear was defined but never called despite the comment
claiming it was. Fixed by adding calls in:
- _smdEndParser → clears the ongoing live-stream parser's tail
- _clearAnchorProseIncrementalNode → clears each anchor-scene
parser's tail before the cache is dropped
All 222 tests remain green.
Code-level fixes for the two issues Greptile + owner review surfaced:
1. SAFETY (Greptile #1 / owner security regression): the original
_smdMediaAwareAddText concatenated prose slices + MEDIA HTML into
one string and handed it to DOMParser. Prose containing HTML
metacharacters (e.g. <img onerror=...>) would be parsed as live
elements. Fixed by routing each prose run through baseAddText
(which calls createTextNode) and only feeding each single MEDIA
token's trusted _inlineMediaHtmlForRef output through DOMParser.
2. CROSS-CHUNK SPLITS (Greptile #2): smd may deliver a MEDIA: token
across two flushes (e.g. "MEDIA:C:\...\" then "foo.png"). The
original implementation would treat each half as separate text
because neither half terminated cleanly. Fixed with a per-parser
tail buffer (WeakMap keyed by parser instance) that holds trailing
bytes ending in "MEDIA:" or MEDIA:<partial-ref> until the next call.
The tail-buffer boundary detection was also improved: the previous
`/[\s\)\]]/.test(rest)` incorrectly rejected "before MEDIA:" (the
whitespace is before MEDIA, not after a completed ref). Replaced
with `rest.endsWith('MEDIA:')` and `/MEDIA:...$/.exec(rest)`.
Architecture: _smdAppendMediaNode is a new single-token DOM insertion
helper that creates a DocumentFragment from one _inlineMediaHtmlForRef
output. _smdMediaTailSet/_smdMediaTailClear manage the per-parser
buffer. Both _safeSmdRenderer and _streamFadeRenderer now route through
the updated interceptor with tail map and parser identity.
Coverage: test_smd_media_in_stream.py tests the architectural invariants
(helper exists, both renderers delegate, recursion guard, entity-safety,
cross-chunk tail). 222 tests pass across all renderer/behaviour suites.
The local agent loop drops:
- a `.playwright-mcp/` directory full of intermediate screenshots while
debugging browser sessions
- a `final-check.png` of the page state at the end of a task
Both are local-only debugging artifacts that should never end up in a
commit. Add them next to the existing `screenshot-*.png` / `full-UI.png`
rules so the same dev-loop garbage collection pattern continues to hold
without anyone having to think about it.
The full renderMd() pipeline maps MEDIA:<path-or-url> tokens to inline
images / video / audio / SVG / PDF / etc. via stash + restore passes.
The streaming smd renderer used by the live assistant body never went
through that pass, so any MEDIA token emitted mid-turn showed up as the
raw path string in the chat window and only became a real <img> once
the turn settled and the full render fired.
Fix:
- Extract the URL/media → HTML mapping in renderMd() into a shared
helper _inlineMediaHtmlForRef(ref, sessionId) so the streaming path
can reuse the same DOM the settled message will eventually render.
- Add _smdMediaAwareAddText in messages.js that wraps smd's add_text
to detect MEDIA tokens in any incoming text chunk and splice the
resulting <img>/<video>/<a>/<div data-path=...> node directly into
the smd parent via a DOMParser-created fragment. Plain prose still
delegates to the renderer it intercepted (so the fade renderer keeps
its word-by-word animation, the safe renderer keeps its O(n) plain
text-node insertion).
- Route _safeSmdRenderer.add_text and _streamFadeRenderer.add_text
through the interceptor. Each fast-paths the no-MEDIA case to the
original baseAddText so we can't recurse into our own wrapper.
Coverage:
- New test_smd_media_in_stream.py locks in the architectural invariants
(helper exists, both renderers delegate to it, recursion guard).
- test_renderer_js_behaviour.py driver now provides a minimal
_inlineMediaHtmlForRef stand-in so renderMd can be invoked standalone
under node without the rest of ui.js in scope (matches the patterns
renderMd already relied on for _IMAGE_EXTS / _SVG_EXTS / _AUDIO_EXTS).
- test_svg_audio_video_rendering.py static-count assertions relax from
">=2" to ">=1" since audio/video/SVG markup now lives in a single
shared function rather than being copy-pasted inside renderMd's
stash-restore closure.