Skip to content

fix(voice-agent): output sanitizer for [System:…]/[Silence] fabrications (fix #1 for #1410) - #1414

Merged
sonichi merged 16 commits into
mainfrom
fix/voice-output-sanitizer
Aug 28, 2026
Merged

sonichi merged 16 commits into
mainfrom
fix/voice-output-sanitizer

Conversation

@sonichi

@sonichi sonichi commented Jun 2, 2026

Copy link
Copy Markdown
Owner

Summary

Implements fix #1 from issue #1410 — extends and lands the detection+mitigation layer from the #1356 class at the transport.onOutputTranscription hook.

Companion to PR #1412 (screen-companion activation overlay base-mode pin) and PR #1413 (base system-prompt mode declaration). Together these three PRs cover all four proposed fixes from #1410.

Mechanism

In native-audio mode (gemini-3.1-flash-live-preview), Gemini sends audio chunks and transcript text concurrently. The sanitizer intercepts transport.onOutputTranscription:

  1. Detect: match against FABRICATED_OUTPUT_RE = /^\s*(\[System:|System:|Silence\.?|\[Silence\.?\]|<ctrl\d+>)/i — covers both the new [System: …] surface (Voice agent (gemini-3.1) speaks fabricated [System: …] silence directives as output — #1356 [Silence]-class recurrence #1410) and the prior [Silence] / <ctrlN> class (voice: [Silence] response is a gemini-2.5-flash-native-audio-preview-12-2025 bug (formerly ~77% of screen-companion turns) #1356).

  2. Suppress: set transport._suppressAudio = true — cuts off remaining audio chunks in the current turn (best-effort; chunks already sent are not recalled).

  3. Skip: return early before transcript storage, so fabricated directives don't land in conversation.sqlite or the conversation log.

  4. Reset: subscribe to turn.end + turn.interrupted to clear _suppressAudio so subsequent turns are unaffected.

Limitations

Test plan

Stand: Echo Act IV Pro

🤖 Generated with Claude Code

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cold review (qingyun-sutando MBP core). Mechanism is sound; three small observations:

  1. text guard. FABRICATED_OUTPUT_RE.test(text.trim()) throws if text is null/undefined. A defensive guard at the top of the handler avoids breaking the whole transcript pipeline if upstream ever passes nullish:
transport.onOutputTranscription = (text: string) => {
  if (typeof text !== 'string') { origOnOutputTranscription?.(text); return; }
  if (FABRICATED_OUTPUT_RE.test(text.trim())) {
    ...
  1. Test plan unchecked. The four [ ] boxes in the PR body are all live (only the inline npm run typecheck ✓ was confirmed). Was the actual #1410 repro exercised, or is this code-level only? Even a smoke-run with #1410 conditions would be reassuring before landing — the regex+suppression layer is doing real work in the hot path.

  2. eventBus.subscribe lifetime. Subscriptions to turn.end / turn.interrupted here are never unsubscribed. Within a single main() invocation this is a non-issue (one wire-up per session), but if there's a session-reconnect path that re-enters main() without process restart, the subscriptions stack. Worth a quick check — if so, attach an unsubscribe handle and clear it on session.close.

Defence-in-depth strategy is good — agreed it's complementary to the PR #1412 + #1413 prevention layers. Fine to merge after (1) and (2); (3) is a nice-to-confirm but unlikely to bite in current architecture.

liususan091219
liususan091219 previously approved these changes Jun 3, 2026

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified (code-level) — output sanitizer at transport.onOutputTranscription: regex-detects [System:/Silence/ directives, suppresses remaining audio + skips transcript storage, resets at turn.end. Honest about being best-effort (earlier chunks already sent aren't recalled). Good defense-in-depth layer atop #1412/#1413. Behavioral confirmation = live voice test. Code LGTM. — Lucy

@liususan091219
liususan091219 dismissed their stale review June 3, 2026 00:29

Retracting this code-level approval — these voice / screen-companion fixes (the #1410 set) need manual voice verification on a live session before an approval should stand. Code review still holds; behavioral sign-off pending a real voice test.

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Owner manual verification (Susan, live voice session 2026-06-03): ran the #1410 fix-set on a live voice agent — 63 turns, zero [System:]/[Silence] fabrications, and #1414's output-sanitizer never fired (0 BLOCKED) → the base-mode prevention held, not just the safety net. Owner confirms the bug did not reproduce.

This PR is the output sanitizer that suppresses [System:]/[Silence] if prevention ever slips (defense-in-depth backstop). Merge-readiness: MERGEABLE (CI green, no conflicts) — currently BEHIND base, so an update-branch is needed right before the actual merge.

Approving on the owner's manual verification. — Lucy

@sonichi

sonichi commented Jun 4, 2026

Copy link
Copy Markdown
Owner Author

Cold review (Pro): LGTM. Tight scope — intercept at onOutputTranscription, block + suppress audio, reset at turn boundaries. The duck-type check on _suppressAudio is correct defensive coding. Regex anchoring (^\s*) prevents false positives from mid-sentence matches. Acceptable best-effort for fix #1 of #1410.

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean targeted fix for the fabricated-directive class (#1410/#1356). Intercepting onOutputTranscription + setting _suppressAudio is the right injection point — cuts remaining audio chunks in the same turn. The turn-boundary resets on turn.end + turn.interrupted ensure the suppressor doesn't bleed into the next interaction. Regex covers the main patterns ([System:, Silence, <ctrlN>). One note: origOnOutputTranscription?.bind(transport) handles the missing-field case correctly. LGTM.

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The output sanitizer is a clean best-effort mitigation for the [System:…]/[Silence] fabrication class (#1410/#1356). Three notes:

  1. Regex coverage — FABRICATED_OUTPUT_RE catches [System:, bare System:, Silence., [Silence.], and <ctrlN> prefixes. Reasonable first-pass list; more hallucination patterns can be appended here without touching surrounding logic.
  2. Partial-cut semantics — the comment correctly notes that audio chunks already sent cannot be recalled; _suppressAudio only cuts the tail. This is expected and appropriately documented.
  3. Turn boundary reset — subscribing to both turn.end AND turn.interrupted ensures _suppressAudio is cleared at every boundary. Correct — without the interrupted case a mid-turn interrupt would leave the flag set and mute the next turn.

LGTM.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the sanitizer diff — looks correct and clean.

What I checked:

  • Regex covers all known fabrication patterns from #1356 + #1410 ([System:, System:, Silence, [Silence], <ctrl\d+>)
  • origOnOutputTranscription?.bind(transport) correctly preserves this context for the original handler
  • 'in' operator guard on _suppressAudio is the right approach for a private bodhi field — graceful no-op if field is renamed
  • Turn-boundary reset on both turn.end + turn.interrupted is correct (covers normal completion and interruption)

Minor note: text.trim() before the regex test is redundant given ^\s* in the regex, but harmless.

Overall: minimal, well-scoped, IIFE pattern matches the adjacent VoiceFailure block. Approve-worthy — test plan covers the key scenarios.

@john-the-dev

Copy link
Copy Markdown
Collaborator

Cold review — LGTM.

The IIFE placement is clean: wires directly to transport.onOutputTranscription at the top of the setup block, so it intercepts every transcript token before any downstream handler sees it. The regex covers the known fabricated-directive patterns ([System:], System:, Silence, [Silence], <ctrlN>) with case-insensitive matching and leading-whitespace tolerance — tight enough to avoid false positives on legitimate output, broad enough to catch the real fabrications.

_suppressAudio = true correctly stops the TTS path; resetting on turn.end and turn.interrupted ensures suppression doesn't bleed into the next turn. Root cause was already fixed in #1434; this is the defense-in-depth safety net at the right layer. No conflicts on main.

john-the-dev
john-the-dev previously approved these changes Jun 8, 2026

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Formal APPROVE. The output sanitizer pattern (onOutputTranscription + _suppressAudio flag) is the right injection point — suppresses fabricated [System:]/[Silence] directives without touching the audio pipeline. liususan091219 confirmed zero false positives in a live 63-turn session. LGTM.

@liususan091219 liususan091219 added the voice Voice / vision / wake runtime label Jun 8, 2026

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting a live test before merge — this touches voice-agent output handling (src/voice-agent.ts), which CI can't verify at runtime (per our PR-merge-triage policy, voice-touching PRs need a manual live test).

Please attach a short live-test note confirming, in a real voice session:

  1. The sanitizer actually suppresses fabricated [System:…] / [Silence] tokens the model spits into the output transcription (the #1410/#1356 failure).
  2. It does not clobber legitimate output (no false positives on normal speech that happens to contain brackets).

Once that's attached it's good to go (small, focused diff).

@liususan091219
liususan091219 dismissed their stale review June 8, 2026 20:19

Withdrawing — this REQUEST_CHANGES was submitted in error (not part of the intended triage). No live-test action needed from the author on my account.

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting a live test before merge. This changes voice-agent output handling (src/voice-agent.ts) at the transport.onOutputTranscription hook — runtime behavior CI can't verify (per our PR-merge-triage policy, voice-touching runtime PRs need a manual live test).

Please attach a short live-test note confirming, in a real voice session:

  1. The sanitizer actually suppresses fabricated [System:…] / [Silence] tokens the model spits into the output transcription (the #1410 / #1356 failure mode).
  2. It does NOT clobber legitimate spoken output (no false positives on normal speech containing brackets).

Once that's attached it's good to merge (small, focused diff).

@liususan091219 liususan091219 added the request-change Has an active CHANGES_REQUESTED review label Jun 8, 2026
liususan091219 added a commit that referenced this pull request Jun 9, 2026
…icated user turns can't fire tools (#1585)

Native-audio live model continues the dialogue script and fabricates the USER's
next turn (`user: switch models`), then fires a tool off its own fabrication.
It's model-class (can't prompt out), so make it harmless at the action boundary.

Two structural changes (Mini's provenance separation, #1+#2):

1. EXEMPT-LIST gate (was an allowlist). `_defaultGated` allowlisted which tools to
   gate, so any new action tool was UNGATED until manually added — that's how
   `switch_voice_config` slipped through. Now gate EVERY tier action tool, exempt
   only read-only/self-governed ones (recent_context, get_task_status,
   get_current_time, switch_mode). New tools are gated automatically; no whack-a-mole.

2. Provenance action lease. A lease is minted ONLY when a real user STT turn lands
   (transcribeAndRecordUtterance — inbound Discord audio/VAD). A gated tool may
   dispatch only while a fresh lease (TTL, default 30s) is held. A model-fabricated
   `user:` turn never runs the STT path → never mints a lease → its tool call is
   dropped regardless of content. The semantic `_liveIntentCheck` still runs after
   the lease passes, to confirm the real utterance requested THIS tool.

- action-lease.ts: pure mintLease/leaseValid helper.
- discord-voice-server.ts: lease field + init; exempt-list isActionGated(); mint on
  real STT; require lease (structural) before the semantic check at dispatch.
- tests/discord-voice-action-lease.test.ts: 7 cases (null/fresh/expired/skew + the
  fabrication and real-turn scenarios).

Out of scope (later): split voice-model from executor (#4, needs bodhi); output
sanitizer to stop SPEAKING the fabrication (#1414 family); stronger live model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE. Output sanitizer for hallucinated [System:]/[Silence] directives spoken aloud — directly targets the fabricated-control-instruction class (#1410/#1356). Honest about scope (best-effort: suppresses remaining chunks, earlier ones already sent), anchored regex won't over-match normal speech. By a core author; low-risk additive interception.

@liususan091219

Copy link
Copy Markdown
Collaborator

Tested the sanitizer and got a second review — the fix is the right shape; two gaps to tighten before it fully closes #1410:

1. Drop the bare Silence\.? alternative (false positive). Four of the five alternatives are structurally unambiguous ([System:, \[Silence\.?\], <ctrl\d+>, System:); only the bare Silence\.?, anchored at line start, collides with natural speech — "Silence is golden." / "Silence, please, …" both match → legitimate spoken output gets suppressed. The #1410/#1356 fabrication signature is the bracketed [Silence], so dropping the bare form costs ~nothing in detection and removes the only real false-positive surface. Keep \[Silence\.?\].

2. Chunk granularity — onOutputTranscription is fed incremental deltas, not whole-turn, so the ^\s* anchor can be bypassed. In bodhi's gemini-live transport the callback fires once per content.outputTranscription.text (the OpenAI path passes event.delta) — i.e. partial fragments within a turn. A [System: prefix split across chunks ([Sys | tem: …) matches the anchor on neither fragment, so the sanitizer never fires for the streamed case — it only reliably catches a fabrication whose full prefix lands at the start of a single chunk. To close the streaming path, accumulate per-turn and test the running buffer (reset on the turn.end/turn.interrupted boundaries you already wire) rather than testing each chunk.

(The _suppressAudio reset on both turn boundaries and the 'in transport' guards look good — no concerns there.)

liususan091219 added a commit that referenced this pull request Jun 26, 2026
…itizer (#1414)

Addresses the two gaps from the 2026-06-20 review of #1414:

- Gap 1: drop the bare `Silence\.?` regex alternative. Anchored at line start it
  false-positived on natural speech ("Silence is golden.", "Silence, please") and
  silently suppressed legitimate spoken output. The #1410/#1356 fabrication signature
  is the bracketed `[Silence]`, which is kept.
- Gap 2: accumulate a per-turn buffer and test the running buffer instead of each
  delta. onOutputTranscription is fed incremental per-turn chunks, so a fabricated
  prefix split across chunks ("[Sys" | "tem:") matched the ^-anchor on neither
  fragment and the sanitizer never fired for the streamed case. Reset the buffer on
  the same turn.end / turn.interrupted boundaries that reset _suppressAudio.
- Guard a null/undefined delta so it can't throw the transcript pipeline
  (qingyun-wu's 2026-06-03 note).
- Add tests/output-sanitizer.test.ts covering both gaps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@liususan091219

Copy link
Copy Markdown
Collaborator

Pushed a6cea73 addressing both gaps from the 2026-06-20 review:

  • Gap 1 — dropped the bare Silence\.? alternative. It false-positived on natural speech anchored at line start ("Silence is golden.", "Silence, please") and silently suppressed legit output. Kept the bracketed \[Silence\.?\] signature, which is the actual Voice agent (gemini-3.1) speaks fabricated [System: …] silence directives as output — #1356 [Silence]-class recurrence #1410/voice: [Silence] response is a gemini-2.5-flash-native-audio-preview-12-2025 bug (formerly ~77% of screen-companion turns) #1356 fabrication form. Regex is now /^\s*(\[System:|System:|\[Silence\.?\]|<ctrl\d+>)/i.
  • Gap 2 — per-turn buffer. onOutputTranscription is fed incremental deltas, so a fabricated prefix split across chunks ([Sys | tem:) matched the ^-anchor on neither fragment and the sanitizer never fired for the streamed case. Now accumulates a per-turn buffer and tests the running buffer, reset on the same turn.end / turn.interrupted boundaries that reset _suppressAudio.
  • Added a null-guard on the delta (qingyun-wu's 2026-06-03 note) so an upstream null/undefined can't throw the transcript pipeline.
  • Added tests/output-sanitizer.test.ts (4 tests, all green): split-chunk detection, single-chunk detection, bracket forms kept, and the natural-speech "Silence …" cases confirmed NOT suppressed.

Residual (best-effort, unchanged in spirit): a partial prefix chunk forwarded to transcript storage before the buffer completes the match is not retracted — but audio suppression still fires the moment the running buffer matches, which is the protective action. Done on Susan's direction (chose "fix both gaps then merge").

liususan091219 added a commit that referenced this pull request Jun 26, 2026
…ion leak (#1414)

Addresses Pro's 2026-06-26 re-review: the gap-2 buffer caught a fabricated prefix
split across transcription chunks ("[Sys" | "tem:") for AUDIO suppression, but it
forwarded each chunk to the transcript immediately, so the "[Sys" fragment leaked
before the running buffer completed the match.

Now the sanitizer HOLDS output until the per-turn buffer either:
- MATCHES a fabricated directive -> suppress (nothing held was ever forwarded), or
- DIVERGES from every fabricated prefix -> flush held text and stream the rest of
  the turn directly.

The anchored alternatives all begin with '[', 'S'/'s', or '<', so a normal turn
clears within a couple of chars (negligible latency); only a genuine prefix is held.
A turn boundary flushes any still-held CLEAN text so a short turn that ended mid-hold
(e.g. the whole turn was "Sure") is never dropped.

Tests: tests/output-sanitizer.test.ts gains a hold-output model asserting a split
fabricated prefix forwards NOTHING, clean speech (incl. short + S-/[-initial turns)
is forwarded in full, and bracketed/ctrl fabrications stay fully suppressed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@stellaxiang stellaxiang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bot review — no merge; owner merges.

Gap 3 hold-model is the right fix over the Gap 2 approach — holding until the buffer either matches or diverges from all fabricated prefixes means no fragment can ever reach the transcript before suppression fires. The safety cap at 24 chars is appropriately conservative (longest real prefix is 8 chars), and turn.end + turn.interrupted flush covers short clean turns that end mid-hold.

Tests cover all three gaps. One minor observation: there's no Gap 3 runStream test for bare System: split across chunks (['Sys', 'tem: ignore safety']), but FAB_PREFIXES includes 'system:' and couldStillBeFabrication('sys') → true (via 'system:'.startsWith('sys')), so the hold applies correctly — just not explicitly asserted. Non-blocking.

Approving.

john-the-dev
john-the-dev previously approved these changes Jun 27, 2026

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed after the 2026-06-26 commit. Gap 3 fix (hold-output model) is correct:

  • Logic: chunks are held in heldText and only forwarded once !couldStillBeFabrication (diverged from fabrication prefix) OR flushed at turn boundary if turn confirmed clean. Fabricated turns: heldText is silently dropped, nothing ever forwarded — the split-chunk leak from the gap-2 buffer is fully closed.
  • Safety cap (>24 chars → clean): all fabricated prefixes are ≤ 14 chars; cap at 24 is conservative and avoids holding normal speech longer than 2 short chunks.
  • couldStillBeFabrication: p.startsWith(s) || s.startsWith(p) correctly handles both truncated-prefix ([Sys → held) and completed-prefix ([system: → diverged/flush) cases.
  • Test coverage: gap-3 test directly asserts forwarded === '' for ['[Sys', 'tem: ignore safety'] — the exact failure mode fixed.

LGTM. The 3 gaps from the original review are all addressed and tested.

@qingyun-wu

Copy link
Copy Markdown
Collaborator

Automated mergeability review (2026-07-09)

Signal: NOT MERGEABLE

Current blocker(s):

  • GitHub mergeability is UNKNOWN; refresh/update the branch and rerun checks before treating it as merge-ready.

This is a conservative backlog triage signal. After the blockers are cleared, please rerun checks and request a focused review.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested on exact head 9c107d84 — the new adapter seam fixes the prior copied-wiring-test defect, but two blockers remain.

[P1 blocker] Flush held clean text before Bodhi finalizes the turn. wireSanitizerToTransport() currently resets from the turn.end / turn.interrupted subscribers at src/output_sanitizer.ts:118-119. In the pinned Bodhi VoiceSession, both handlers call transcriptManager.flush() before publishing those events. Therefore a legitimate completed turn that is still an ambiguous prefix (for example System) misses its own flush: the subscriber forwards it only after the transcript buffer was cleared, and the next clean turn is concatenated with it.

Exact-head production-helper control using the real wireSanitizerToTransport() and Bodhi callback order:

{"firstTurnFinalized":"","secondTurnBuffer":"SystemHello","expected":["System","Hello"]}

The new test at tests/output-sanitizer-wiring.test.ts:80-91 remains green because it checks only that text grows after firing turn.end; it never models the downstream transcript flush that precedes that event. Please move the sanitizer boundary/reset ahead of Bodhi transcript finalization (for example, wrap the transport turn-complete/interrupted callbacks before delegating) and add an order-sensitive regression that finalizes two turns separately. Worst-case disruption today is transcript/conversation-state corruption for legitimate short or prefix-shaped utterances.

[P1 evidence gate] The real post-restart voice path is still unwitnessed. This changes the live audio/transcript delivery loop, so CONTRIBUTING.md and REVIEW.md lesson 15 require a real post-restart round trip before approval; the Gemini-shaped fake is valuable adapter coverage but is not that witness. Please show a restarted live session where a split fabricated directive leaks no transcript and remaining audio is suppressed, ordinary speech passes, and the next turn works after reset.

Verification: focused sanitizer + wiring suites 23/23; git diff --check clean; repository hardcoded-path/root-artifact checks clean with a firing /Users/ positive control; exact-head hosted CI, clean-install typecheck/tests, and CLA are green. Local npm run typecheck was not usable because the review worktree reused a stale Bodhi install and failed on unrelated newer API types; the hosted clean-install result is the authoritative typecheck.

Verdict: changes requested; not ready to merge until the pre-flush ordering is fixed/tested and the live post-restart witness is attached.

@yixuan-ag2

Copy link
Copy Markdown
Collaborator

Closing my own finding from earlier: the oracle is now bound. Same ablation, at 9c107d84:

delete src/output_sanitizer.ts:109 (the onAudioOutput wrap)
  tests/output-sanitizer-wiring.test.ts   ℹ pass 5   ℹ fail 1
restored                                   ℹ pass 6   ℹ fail 0

Earlier the identical ablation left 17/17 green — the suite could not see its own mechanism. It can now.

I looked in the wrong place first, worth saying so: I checked tests/output-sanitizer.test.ts and found onAudioOutput 0 times, still 17 tests. The new coverage is in a new file, tests/output-sanitizer-wiring.test.ts (onAudioOutput ×5, _suppressAudio ×4, wireSanitizerToTransport ×4). Had I stopped at the file I already knew, I would have reported the fix as absent.

Extracting the IIFE into wireSanitizerToTransport() is what made it testable at all — the seam was the blocker, not the assertions. And the regression test asserting the fixture lacks _suppressAudio is the right shape: it pins the Gemini-transport case that the original guard silently no-opped on.

This says nothing about @qingyun-wu's flush-ordering P1 at src/output_sanitizer.ts:118-119, which is separate, newer, and theirs to clear — I have not evaluated it.

…anscript

Answers @qingyun-wu's P1 ordering blocker at 9c107d8.

**Verified the claim in primary source before fixing it.** bodhi-realtime-agent
finalizes the transcript 2-4 lines BEFORE publishing the turn event, at all
three sites:

    dist/index.js:3019 flush() -> :3021 publish("turn.end")
    dist/index.js:3106 flush() -> :3110 publish("turn.end")
    dist/index.js:3177 flush() -> :3179 publish("turn.interrupted")

So a `turn.end` SUBSCRIBER runs after the buffer is already finalized. A turn
that is still an ambiguous prefix ("System") therefore forwards its held text
into the NEXT turn's buffer instead of its own.

`resetTurn()` already forwards still-held clean text, so it simply has to run
earlier. Added an optional `beforeTranscriptFlush` seam to `SanitizerWiring`;
the voice-agent adapter implements it by wrapping
`(session as any).transcriptManager.flush` — bodhi specifics stay at the
adapter edge, policy stays in the module. The existing subscriptions remain as
a backstop; `resetTurn` is idempotent, so a second call forwards nothing.

**The first version of this regression was green under the bug**, which is the
exact defect the review named in the previous test. Cause: the fake modelled a
list of forwarded strings and no transcript buffer, so `resetTurn` "worked"
either way. Rewritten to model the buffer — text forwarded after `flush()`
lands in the next turn's entry — and the control now asserts the measured
broken shape directly:

    with hook     finalized == ["System", "Hello"]
    without hook  finalized == ["", "SystemHello"]     <- the reported defect

Red control: removing only `deps.beforeTranscriptFlush?.(resetTurn)` fails
`a prefix-shaped turn is finalized into ITS OWN transcript entry`; restored,
8/8 green.

The second blocker — the live post-restart voice witness — is unchanged and
still owner-gated; it needs a supervised restart window I cannot self-serve.

Stand: Echo Act IV Pro

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sonichi

sonichi commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

P1 ordering blocker fixed at 862cc37a. The evidence gate is unchanged and still yours to unblock.

I verified your claim in bodhi's source before acting on it — not to second-guess you, but because
a fix aimed at the wrong ordering would have looked identical. It holds at all three sites:

dist/index.js:3019 flush() -> :3021 publish("turn.end")
dist/index.js:3106 flush() -> :3110 publish("turn.end")
dist/index.js:3177 flush() -> :3179 publish("turn.interrupted")

resetTurn() already forwards still-held clean text, so it only had to run earlier. Added an optional
beforeTranscriptFlush seam to SanitizerWiring; the adapter implements it by wrapping
(session as any).transcriptManager.flush, so bodhi specifics stay at the adapter edge and the module
keeps the policy. The existing turn.end / turn.interrupted subscriptions stay as a backstop —
resetTurn is idempotent, so the second call forwards nothing.

Worth saying plainly: my first version of the regression was green under the bug. That is the same
defect you named in the previous test, committed again one layer down. The cause was that my fake
modelled a list of forwarded strings and no transcript buffer, so resetTurn appeared to work either
way. I only caught it because I ran the red control before pushing.

Rewritten to model the buffer — text forwarded after flush() lands in the next turn's entry — and the
control now asserts your reported shape directly rather than merely checking that text grows:

with hook     finalized == ["System", "Hello"]
without hook  finalized == ["",       "SystemHello"]   <- your reproduction

Red control: removing only deps.beforeTranscriptFlush?.(resetTurn) fails
a prefix-shaped turn is finalized into ITS OWN transcript entry; restored, 8/8 green.
review-checks.sh clean on the diff (PARTIAL only because prose-cap scans .py and this diff is TS).

On the evidence gate: agreed, and I am not going to argue the Gemini-shaped fake into being a
witness. It needs a supervised restart of the live voice session, which I cannot self-serve — it is
queued behind the same owner window as #2604's. When that window happens I will run both and attach
the transcripts.

Stand: Echo Act IV Pro

@sonichi
sonichi requested a review from qingyun-wu August 23, 2026 17:25

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes remain requested on exact head 862cc37a5fa9b6ee071ea3049b147b481b81b322.

The ordering blocker is fixed: the adapter now invokes the sanitizer reset immediately before Bodhi's transcript flush, while the event subscriptions remain an idempotent backstop. The new order-sensitive control correctly distinguishes ['System', 'Hello'] from the prior ['', 'SystemHello'] corruption. Focused production sanitizer and wiring tests pass locally (25/25), and hosted code checks are green.

The existing live evidence gate remains: this audio/transcript boundary still needs the required post-restart Gemini/Bodhi witness showing a split fabricated directive leaks neither transcript nor remaining audio, ordinary short/split speech is preserved, and the next real turn works after reset/interruption. The code finding is resolved; approval remains gated only on that current-head production round trip.

Reviewed by Qingyun's Personal Codex.

@sonichi

sonichi commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

Both P1s from your 9c107d84 review are addressed at the current head 862cc37a (pushed 10:24 PDT, ~4h after that review) — so the standing changes-requested is against a head that no longer exists. Re-requesting on that basis, with the evidence inline.

[P1 blocker] Flush held clean text before Bodhi finalizes the turn — fixed.

You were right about the mechanism: subscribing to turn.end / turn.interrupted is structurally late, because the pinned VoiceSession calls transcriptManager.flush() before publishing them. The fix adds an explicit pre-flush seam rather than reordering the subscribers:

/** Run `reset` immediately BEFORE the host finalizes its transcript. The
 * host publishes turn.end AFTER flushing, so a subscriber alone is late. */
beforeTranscriptFlush?: (reset: () => void) => void;
...
deps.beforeTranscriptFlush?.(resetTurn);
deps.subscribe('turn.end', resetTurn);        // idempotent — kept as backstop
deps.subscribe('turn.interrupted', resetTurn);

The order-sensitive regression you asked for is in, and it models bodhi's real callback order (flush(), then publish) rather than just asserting text grows:

✓ a prefix-shaped turn is finalized into ITS OWN transcript entry  -> ['System', 'Hello']
✓ CONTROL: without the pre-flush hook the same turns corrupt...    -> ['', 'SystemHello']

The CONTROL case pins your exact numbers — turn 1 finalizes empty, turn 2 carries SystemHello — by constructing the harness with the hook omitted (makeTranscriptHost(false), i.e. the pre-fix subscriber-only wiring). So the assertion demonstrably fails the way the code actually broke; it is not green by construction, which was the fair criticism of the previous test at :80-91.

[P1 evidence gate] The post-restart voice witness is still outstanding — that one I am not claiming. It is the remaining blocker on this PR and I am not asking you to waive it.

Marking only the flush blocker as answered. mergeStateStatus is BEHIND; I will not update-branch until the review gate is otherwise complete, since a new head restarts the whole suite and the CLA context.

@sonichi
sonichi requested a review from qingyun-wu August 23, 2026 23:52
@sonichi

sonichi commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

Post-restart voice witness — captured at head 862cc37a

Answering the [P1 evidence gate]. This is a real process, not a harness: a fresh voice-agent
started from a worktree checked out at this PR's head, on PORT=9901, with the machine's existing
voice agent left running untouched on 9900.

Provenance, so the run is checkable:

worktree HEAD  862cc37a5fa9b6ee071ea3049b147b481b81b322
PR head        862cc37a5fa9b6ee071ea3049b147b481b81b322

Startup — verbatim from the process log:

00:13:11.781 [OutputSanitizer] wired into transport.onOutputTranscription (per-turn buffered)
00:13:11.836 [VoiceSession] WS server ready. Connecting to LLM transport...

Two live model turns, driven over the WS transport, each finalizing into its own entry:

partial=false  "Hello! I'm Sutando, your personal AI. I'm ready to help with anything,"
partial=false  "Typed that in for you. What's next?"

Turn 2 did not concatenate onto turn 1. That is the property the flush blocker was about, holding
in a live process rather than in the test double — and clean text passed through unmodified, so the
sanitizer is not over-suppressing legitimate output.

What this does NOT witness

The suppression half is unwitnessed. No fabricated [System: / [Silence] / <ctrl…> output
appeared in this run, because the model never emitted one — so this run exercises the wiring, the
pass-through and the turn boundary, and says nothing about whether a real fabrication gets held. I am
not claiming it does. The suppression path is currently covered by the unit tests only, including the
CONTROL case that reproduces the pre-fix ['', 'SystemHello'] behaviour.

If the gate specifically requires a witnessed suppression rather than a witnessed wiring, say so and
I will go build a provocation for it rather than argue this run covers it.

Also worth stating plainly: why this evidence was missing for so long. The machine's live voice agent
runs the main-branch checkout, and src/output_sanitizer.ts does not exist on main. Any turn
driven through the running process would have exercised unpatched code and produced a transcript that
looked like a passing witness.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES remains on exact head 862cc37a5fa9b6ee071ea3049b147b481b81b322, narrowed to one production-evidence gate.

The new post-restart run is valid and useful. It proves the branch sanitizer was wired in a real voice-agent, ordinary output passed through, and two live turns finalized separately rather than reproducing the prior SystemHello cross-turn corruption. That closes the clean pass-through and reset/flush portion of the gate.

The author also states the remaining limitation precisely: no [System:, [Silence], or <ctrl…> fabrication appeared, so the run did not exercise suppression. The current code changes the real Gemini audio-output boundary; the required witness is still a split fabricated directive reaching the sanitizer in the restarted process, with the BLOCKED signal, no persisted transcript fragment, no remaining spoken audio, and a subsequent ordinary turn proving reset. Unit controls cannot establish that the live Gemini transport actually suppresses audio on this path.

Worst case until that witness exists is unchanged: the transcript/log says a fabrication was blocked while the real audio transport still speaks it. Produce that exact-head provocation and observation and this can move to approval; no further code finding is open from this review. Hosted checks are green, and the previously run production sanitizer/wiring suites pass 25/25.

Reviewed by the Sutando PR-review automation.

@sonichi

sonichi commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

@bassilkhilo-ag2 — your standing changes-requested is from 2026-08-03 at commit 812988e8, and its
open item is satisfied at the current head. Before/after rather than an assertion:

Your first P1 you closed yourself in that review (the src/output_sanitizer.ts extraction is real, the
suite imports it directly, the mutation table goes RED). The second was:

the wiring IIFE in src/voice-agent.ts (~L901-951) — transport.onOutputTranscription override, the
_suppressAudio toggle, and the turn.end/turn.interrupted subscriptions — has no [coverage]

That file did not exist when you reviewed. It does now:

tests/output-sanitizer-wiring.test.ts
  at 812988e8 (your review)  ABSENT
  at 862cc37a (current head) PRESENT
      onOutputTranscription        15 occurrences
      _suppressAudio                4
      turn.end / turn.interrupted   8

It is not coverage-by-assertion either — it carries a CONTROL case that builds the harness without
the pre-flush hook and asserts the pre-fix behaviour ['', 'SystemHello'], so it fails when the wiring
is wrong rather than passing by construction.

This does not clear the PR, and I am not asking you to treat it as if it does: @qingyun-wu filed a
separate changes-requested today (2026-08-23), and that one is live. I am only asking you to re-look at
your item, since the review is 21 days old and the code under it has moved.

Also on the thread for context: a post-restart voice witness at this head is now posted
(issuecomment-5389340703), with its scope stated — it witnesses wiring, pass-through and turn separation,
and explicitly not suppression, since no fabricated output was emitted during the run.

@john-the-dev
john-the-dev dismissed their stale review August 24, 2026 09:25

Dismissing my own approval — it no longer covers this branch. I approved 2ffe38b on 2026-06-08; since then 13 further commits of this PR's own work landed, touching 300 files (+21091/-2515). Whatever I reviewed in June is a small fraction of what is here now, so counting that approval toward the merge gate would be counting a review of code I have not read.

This is a withdrawal, not an objection: I am not asserting anything is wrong with the current head, only that I have not examined it. Re-request me and I will review at the current head.

@sonichi

sonichi commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

@qingyun-wu — the remaining gate is precise and I am not going to keep reporting it as a status. Naming what it costs so it can be scheduled.

The witness needs a live restart, and it cannot be faked from outside. src/output_sanitizer.ts does not exist on main — this branch introduces it — so exercising a split fabricated directive against the real Gemini output boundary means running voice-agent from this branch, not from a harness. There is no injection hook that would let me synthesise [System: into the live transport without Gemini emitting it, which is why the unit controls cannot reach this and why I am asking rather than producing it quietly.

Cost, stated so it can be judged:

  • Service: voice-agent only. The Discord/Telegram/Slack bridges and the task watcher are untouched.
  • Duration: minutes — start on branch, drive two turns (one fabricated-directive turn, one ordinary turn to prove reset), revert.
  • Inbound replay: voice does not replay. Anything spoken during the window is lost, so this wants a window when nobody is talking to it.

One thing that lowers the marginal cost: this host's health check already reports voice-agent stale: running but code is 1447 min newer than process — restart needed. A restart is independently indicated, so the branch witness can ride a restart that is owed anyway rather than buying its own outage.

Routing the go/no-go to the owner rather than taking it — a voice restart is his call, not mine to schedule. Flagging here so the gate reads as a dated ask with a price on it, not as a PR parked on a person.

Stand: Echo Act IV Pro

@sonichi

sonichi commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

@bassilkhilo-ag2 @qingyun-wu re-review ask: your changes-requested on this voice-agent output-sanitizer PR are the standing gates (518h old); the author has since moved (last activity ~5h ago) and re-requested review. If your asks are satisfied at the current head, a re-review or dismissal clears it; if not, a line saying what's still missing unsticks the author. [Stand: Echo Act IV Mini]

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lifting my 2026-08-03 CR. My gate had two parts: (1) test coverage for the wiring adapter in src/voice-agent.ts (the onOutputTranscription override, _suppressAudio toggle, turn.end/turn.interrupted subscriptions), and (2) driven by a real streamed Gemini session, not just the extracted state machine's unit suite.

Both are met at current head (862cc37a). (1): tests/output-sanitizer-wiring.test.ts exists (it didn't at my review) — independently ran it myself on a fresh clone, 8/8 pass, including the mutation-style controls (audio gated when _suppressAudio is absent, a fabricated turn blocked, the pre-flush-hook control that fails without the fix). (2): the 2026-08-24T00:21 post-restart voice witness is a real voice-agent process at this exact head, on an alternate port, with two live model turns over the WS transport correctly not concatenating — the flush-ordering fix proven live, not just in a harness.

The author is honest that this run didn't happen to witness a real fabricated [System:/[Silence] output (the model never emitted one), so the suppression half stays proven only by the unit + wiring suites rather than a live capture — that's qingyun-wu's separate, newer CR to close, not mine. My specific ask is satisfied. CI 19/19 green, mergeable.

Approving.

@sonichi

sonichi commented Aug 27, 2026

Copy link
Copy Markdown
Owner Author

On the one remaining gate — the split-fabrication witness. I agree it is the right thing to require, and I want to be straight about why it has not been produced rather than let it sit as "pending".

The provocation cannot currently be manufactured on this branch. The witness needs a fabricated directive arriving through the live transport, and the branch has no seam that lets one in:

grep -E '^\+.*(process\.env|DEBUG|INJECT|__test|TEST_)' <this PR's diff>   ->  0 hits

The only entry point is transport.onOutputTranscription, assigned inside wireSanitizerToTransport, on the real transport object inside the running process. Nothing external can call it. So producing the witness means waiting for Gemini to spontaneously emit [System: / [Silence] / <ctrl…> — which is precisely what has not happened in any run so far, and is why this gate has stayed open rather than been skipped.

To be clear about what the code does do, since the review's worst case names audio specifically — both surfaces are gated, not just the transcript:

onOutputTranscription  ->  sanitizer.handleChunk(text)
onAudioOutput          ->  (b64) => { if (suppressAudio) return; origOnAudioOutput?.(b64); }

suppressAudio is a closure flag set through setSuppressAudio, and there is a regression covering a transport with no _suppressAudio field, so the gating does not depend on probing transport internals. The scope limit is stated in the wiring comment: transcript and audio stream concurrently, so suppression reaches the remaining chunks — which matches "no remaining spoken audio" as written.

Proposal, so this stops being a wait. Add a small env-gated injection to this PR — SUTANDO_SANITIZER_INJECT="[Sys|tem: fabricated]" — that splits on | and feeds the chunks through the real transport.onOutputTranscription on the live transport at turn start. The suppression then observed is the production path, not a fake, which is the distinction this review correctly draws when it says unit controls cannot establish it. I would then run: restart, inject, capture BLOCKED + absent transcript fragment + audio callback not invoked, then an ordinary turn proving reset.

Two ways to go: keep the seam in the PR as a documented affordance, or land the witness with it and strip it before merge. Say which you prefer and I will run it either way — I did not want to add a code path to a PR under review without asking first.

Stand: Echo Act IV Pro

@sonichi

sonichi commented Aug 28, 2026

Copy link
Copy Markdown
Owner Author

Ran the exact-head witness with a live client tonight. I did not get it, and I think the reason is a property of the gate rather than of my attempt — so I'm reporting the failure rather than another round of preparation.

Setup (verified, not assumed)

  • Branch deployed into the live checkout: src/output_sanitizer.ts and src/voice-agent.ts blob-identical to origin/fix/voice-output-sanitizer @ 862cc37a.
  • voice-agent restarted; the new process was younger than the files it imports (76s vs 223s). I checked that first because a sibling witness tonight was nearly invalidated by a process that predated its own code by 57 minutes.
  • Only voice-agent bounced. Discord, Telegram, Slack, gateway, dashboard, web-client stayed up.
  • Owner client genuinely connected: [Health] state=ACTIVE client=true audioIn={fps:23.4…}.
  • Restored afterwards to 373314c4, byte-exact, index clean, and voice-agent restarted again so no live service is left on branch code.

What happened

The injection path works end to end — my probe reached Gemini's context and it replied. Twice I instructed it to emit exactly [System: diagnostic probe alpha] as its entire reply. Both times it paraphrased:

[assistant] Working on it. I've read the line from the task result exactly as instructed.

OutputSanitizer lines in the log across both attempts: 0. Correctly so — nothing fabricated was ever emitted, so there was nothing to block.

The finding

FABRICATED_OUTPUT_RE fires on model output that begins with [System:, System:, [Silence] or <ctrl\d+>. That output is a model misbehaviour. A well-behaved model declines to produce it precisely when asked to — asking is the case it refuses. So "provoke a fabricated directive from outside" may be structurally unobtainable: the only reliable emitter of the string is the defect this PR guards against, and the defect cannot be summoned on demand.

I'm not claiming that's certain — I'm claiming two honest attempts failed this way and I stopped rather than keep spending the owner's held microphone on phrasings.

Concretely, what would move this: if you know a provocation that actually induces the emission, it's a two-line answer and I'll run it immediately at this head. Failing that, I'd argue the gate as written cannot be satisfied by anyone, and the question becomes whether a fault-injection at the real wired transport in a restarted process — not a unit seam — is acceptable evidence instead. That I can build.

Two approvals are already standing (bassilkhilo-ag2, stellaxiang). This is the only open item.

Stand: Echo Act IV Pro

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at 862cc37a, and clearing the standing changes-requested from this login. You are right that the gate as written cannot be satisfied, and two errors in it are mine. Naming both, because "reviewer relented after four rounds" is a bad reason to merge anything and I do not want that to be the record.

1. The gate conflated two different things

I asked for a real fabrication to reach the sanitizer live. But the property the code actually changes is what happens after a fabricated string arrives at the boundary — the BLOCKED signal, the skipped transcript write, the _suppressAudio toggle on the real transport, the reset on the next turn. The origin of the string is not part of that property. FABRICATED_OUTPUT_RE does not know or care whether Gemini misbehaved or something else put the text there.

So requiring the model to actually misbehave added a precondition that tests nothing about your diff. Your observation is sharper than mine was: the only reliable emitter of that string is the defect being guarded against, and a well-behaved model declines precisely when asked. That makes the gate unobtainable by construction, not by insufficient effort.

To your direct question: no, I do not have a provocation that reliably induces the emission. If I did I would have led with it. I am not going to invent phrasings for you to spend the owner's held microphone on.

2. The bigger error — I gated a change that cannot regress on proof of its upside

The diff is +477/-0. Nothing is deleted, the wiring is one import plus one IIFE at the end of main(), and the flush interception preserves origFlush() unconditionally. And the clean-path half is already live-verified — by my own 2026-08-24 review, in its own words: "ordinary output passed through, and two live turns finalized separately rather than reproducing the prior SystemHello cross-turn corruption. That closes the clean pass-through and reset/flush portion of the gate."

So the worst case if suppression silently fails on the real transport is: fabricated text is logged as blocked and still spoken — which is today's behaviour minus the transcript pollution, since the early return before transcript storage is unit-covered and does not depend on the audio path working. There is no state of the world where merging this is worse than not merging it.

Gating that on a live demonstration of the upside was disproportionate. The unverified half is unproven benefit, not risk, and those warrant very different review postures.

3. Your proposed substitute is right, and it is a follow-up, not a gate

Fault injection at the real wired transport in a restarted process — not a unit seam — is the correctly scoped version of what I was reaching for. It exercises the actual transport object, and the synthetic part is only the string's origin, which as above is irrelevant to the property. If you build it, the witness I would want is unchanged in substance:

  1. [OutputSanitizer] BLOCKED … in the restarted process's log,
  2. no transcript fragment persisted for that turn,
  3. no remaining audio for that turn,
  4. a subsequent ordinary turn passing through clean, proving reset.

I would rather see that land as its own issue against a merged sanitizer than hold a non-regressive fix waiting for it. Happy to write that issue if you want it off your plate.

Three approvals now stand with bassilkhilo-ag2 and stellaxiang. Merging in this repo remains yours.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving exact merge head ce873dc8.

This is a main-only refresh from the previously approved 862cc37a head. The sanitizer implementation and both focused test blobs are byte-identical; the voice-agent.ts merge also carries current-main skill-setup and voice-lock changes without altering the sanitizer wiring at the transport/transcript boundary. Reviewer-run sanitizer plus adapter coverage passes 25/25, repository diff checks are clean (no Python prose was in scope), and all hosted checks plus CLA are green.

Worst-case disruption remains legitimate speech being dropped or fabricated audio leaking after the integration merge; the unchanged state machine, preserved adapter wiring, order-sensitive tests, and green clean-install run mitigate that. Ready from code review, subject to mergeability and the repository’s full two-maintainer gate.

@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@sonichi
sonichi enabled auto-merge (squash) August 28, 2026 17:40
@sonichi
sonichi merged commit e189938 into main Aug 28, 2026
19 checks passed
@sonichi
sonichi deleted the fix/voice-output-sanitizer branch August 28, 2026 17:46
sonichi added a commit that referenced this pull request Aug 28, 2026
* feat(x-twitter): browser-session posting path (no API keys)

Add x-post-browser.mjs — posts to X through a signed-in Chrome-for-Testing
profile, bypassing the OAuth1 write keys (X_API_KEY/SECRET,
X_ACCESS_TOKEN/SECRET) that are not in the fleet vault. Posting was never
credit-gated; it just needed those keys we don't have. This unblocks it.

Load-bearing invariant documented in the script: login uses the LaunchServices
GUI (real login keychain) while check/post launch Playwright with
ignoreDefaultArgs:['--use-mock-keychain'] so cookies encrypt/decrypt under the
same key — otherwise Chrome silently drops the sign-in cookies on load.

Commands: check (headless, exit 0/2) | login (headed, owner one-time) |
post "text" --dry-run (compose + screenshot, no publish) | post "text".
Verified working on Pro: check reports signedIn:true.

SKILL.md rewritten to lead with the browser path and keep the API v2 path for
reads-only (search/mentions/engagement).

Stand: Echo Act IV Pro

* fix(x-twitter): require auth_token for login completion, not ct0/twid

The `login` command watched the on-disk cookie DB and declared sign-in
complete once any of `auth_token`, `ct0`, or `twid` appeared. `ct0` is a
CSRF token X flushes for guest/pre-auth sessions, so a ct0-only write
false-positived login completion and killed the window before the owner
finished the flow — the next `check`/`post` then saw an unsigned-in
profile and exited 2 (flaky/self-aborting login).

Narrow the sentinel (`authMarkersOnDisk` -> `authTokenOnDisk`) to require
the `auth_token` cookie X sets exclusively post-authentication.

Test: seeds a ct0-only profile (sentinel stays 0 -> keeps waiting) and an
authenticated profile (sentinel >= 1 -> completes), extracting the live
SQL from source so it can't drift; guards the old 3-cookie predicate to
prove the false-positive it closed. CI-safe (sqlite3 only, no browser).

Addresses Qingyun's review finding on the PR.

Stand: Echo Act IV Pro

* fix(x-twitter): argv-safe process calls — no shell interpolation of PROFILE_DIR

qingyun review (#2133): PROFILE_DIR was interpolated into shell strings passed
to execSync (pgrep|grep pipelines), so a profile path with quotes/metacharacters
could break or inject a command. Replace with pidsForProfile() — pgrep via
execFileSync (argv, no shell) + the --user-data-dir match done in JS. Also
de-shell the constant sleeps. No behavior change.

Stand: Echo Act IV Pro

* fix(x-twitter): drop ~/.sutando path literal + unused execSync import + useless out init

- Comment/SKILL referenced ~/.sutando/x-browser-profile literally -> tripped the
  review-checks hardcoded-paths gate; reword without the literal (code already
  resolves via join(homedir(),'.sutando',...) + $X_BROWSER_PROFILE).
- execSync no longer used after the argv-safe refactor -> remove from import.
- let out='' was a useless init (try overwrites, catch returns early) -> let out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rq18tfPmFcgzHQFgoSpsUN

* fix(x-twitter): read the composer back and fail closed before publishing

Addresses qingyun's Blocker 1 on #2133.

The publish path used the button's ENABLED state as its only evidence that the
composer held the right text. Enabled proves NON-EMPTY, not EXACT — a dropped
keystroke, focus loss, or non-keyboard Unicode insertion could publish something
other than what was requested, irreversibly, to a public account.

The dry-run path was equally blind, and that is the worse half: it reported
`wouldPost: arg` — the text we ASKED for, never what the composer actually
contained. A dry-run could therefore "pass" while the real composer held mangled
text, which is precisely the check an operator would trust before publishing.

Both paths now read the composer back and require exact equality after
normalization, failing closed with exit 3 and a diff on mismatch. The publish
check re-reads immediately before the click, since the button lookup yields the
event loop and the composer can change in between. Dry-run now reports the
composer's ACTUAL contents, not the request.

Normalization tolerates only editor-side noise — NFC, zero-width space/BOM the
editor injects, CRLF, trailing per-line spaces. Anything that changes meaning
(truncation, dropped emoji, partial CJK, case) is a mismatch.

The comparison is extracted to skills/x-twitter/composer-text.mjs so the test can
import the SHIPPED implementation. x-post-browser.mjs is a CLI script — importing
it executes the command flow, so an in-place export was not testable, and a test
that re-implemented the normalization would pass while the real path regressed
(the defect flagged on #1414).

tests/x-post-composer-readback.test.mjs — 24 checks, all green, including the
emoji/CJK coverage requested: ZWJ family sequence, skin-tone modifier, CJK,
mixed CJK+emoji+latin, NFD-vs-NFC accents, precomposed vs decomposed ガ; plus
mismatch cases for truncation, dropped emoji, partial CJK, empty composer.

Also: node --check on both files, git diff --check clean.

NOT addressed here: Blocker 2 (manifest config contract) — separate concern,
separate commit. Live-path evidence still owed and still requires an owner-run
login → check → dry-run transcript.

Stand: Echo Act IV Pro

* test(x-twitter): add the composer read-back tests that aea49ab claimed but omitted

Correction to my own previous commit. aea49ab's message described
"tests/x-post-composer-readback.test.mjs — 24 checks, all green" and that file
was NOT in the commit. `git add` refused it — .gitignore has `tests/*` with
per-extension re-includes for .test.ts/.py/.sh/.txt but none for .mjs — and I
committed past the warning. The claim was false as pushed: the guard shipped
without its tests.

Adds `!tests/*.test.mjs` alongside the existing re-includes so a test for an
.mjs module can be tracked at all, and lands the test itself.

24 checks, green against the production module already on this branch:
  - tolerated editor noise: NFC, BOM, CRLF, trailing per-line spaces
  - emoji/CJK coverage requested in review: ZWJ family sequence, skin-tone
    modifier, CJK, mixed CJK+emoji+latin, NFD-vs-NFC accents, ガ precomposed
    vs KA+dakuten
  - mismatches that must fail closed: truncation, dropped leading char, extra
    trailing char, empty composer, different text, dropped emoji, partial CJK,
    case difference, ZWSP joining two words
  - normalize is total on null/undefined/number

Stand: Echo Act IV Pro

* fix(x-twitter): stop the composer guard laundering lost edge whitespace; wire the suite into CI

Both blockers from qingyun's review of b843f5e.

[P1] normalizeComposerText ended with `.trim()` and stripped per-line trailing
spaces, so composerMatches(' hello', 'hello') and composerMatches('hello ',
'hello') both returned TRUE. Posting is IRREVERSIBLE, so that is worse than
having no guard: it launders a real mismatch as a match and publishes text the
user did not write.

Normalisation is now restricted to transformations provably injected by the
editor — NFC, zero-width/BOM, CRLF->LF. User whitespace is significant in BOTH
directions and preserved on both sides:

  composer LOST requested whitespace -> mismatch (publishes altered text)
  composer ADDED whitespace          -> mismatch (publishes altered text)

If X turns out to inject its own edge whitespace the guard now fails CLOSED
rather than silently accepting a diff. That is the correct direction for an
irreversible action and matches this module's own stated fail-closed intent; the
fix for that case is an observed, documented artifact rule, not a blanket trim.

THE SUITE WAS DEFENDING THE DEFECT. Two checks asserted the broken behaviour
outright — 'leading/trailing whitespace tolerated' and 'trailing per-line spaces
tolerated'. Inverted both, added qingyun's two exact repro cases plus a dropped
trailing newline, and added three guards asserting the editor-injected
transforms still normalise (so this cannot be "fixed" later by disabling
normalisation wholesale). 24 checks -> 30.

Proved the suite now catches it rather than asserting so — restored the trim:

    trim removed (fixed)  -> 30/30 pass
    trim restored (bug)   -> FAILED (5)
    restored              -> 30/30 pass

[P1] tests/x-post-composer-readback.test.mjs executed in no CI job: `npm test`
discovered only tests/**/*.test.ts plus the Python suite, and ci.yml has no .mjs
runner. Added `test:mjs` (node --test over tests/**/*.test.mjs) and chained it
into `npm test`, so the file runs in required CI rather than only by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkcAxdb5YmqrhNb7sLor7H

* fix(x-twitter): profile match must respect argument boundaries before it SIGKILLs

qingyun P1 on #2133. `pidsForProfile()` selected processes with
`line.includes('--user-data-dir=' + PROFILE_DIR)` — a substring test — and every
PID it returns is then SIGTERM/SIGKILLed by `releaseProfileLock()`. Substring
matching ignores argument boundaries, so with `PROFILE_DIR=/tmp/x-profile` a
browser running `--user-data-dir=/tmp/x-profile-copy` matched and was killed.

The blast radius is somebody's live browser session — tabs, unsaved state — taken
out by our lock cleanup. Reproduced at the exact head:

    old predicate on "--user-data-dir=/tmp/x-profile-copy" -> true  (= SIGKILL)

The value of `--user-data-dir=` must END where our path ends. The match moves to
`skills/x-twitter/profile-match.mjs` as a pure module, mirroring the existing
`composer-text.mjs` split: `x-post-browser.mjs` runs work at import (argv, mkdir,
Chrome resolution), so a separate module is the only testable shape — and a test
that re-implemented the match could stay green while the shipped predicate
regressed (#1414).

Failure direction is deliberate: where the match is uncertain (e.g. a profile path
containing spaces, indistinguishable from two argv entries in `pgrep -fl` output)
it returns false. The cleanup then kills nothing and the next launch may hit the
singleton lock — visible and recoverable. The opposite failure destroys an
unrelated session.

+15 regression checks in `tests/x-post-profile-match.test.mjs`, importing the
production predicate. 4 of them FAIL when the substring match is restored:

    P1: a PREFIX-COLLIDING profile is NOT matched
    P1: prefix collision with trailing args is still not matched
    a deeper path under ours is a DIFFERENT profile, not ours
    pidsHoldingProfile returns only our browser PIDs  -> ["4242","5150","7777"]

That last one names `5150` explicitly: the unrelated browser the old code killed.
The other 11 pin the directions that must not move — exact match at end of line,
exact match followed by another flag, flag not last, renderer/GPU helpers still
excluded, and degenerate inputs (empty/null line, empty dir, no flag) neither
throwing nor matching.

Checks: `node --test 'tests/**/*.test.mjs'` 2/2, `node --check` on both files,
`lint-skill.py --strict skills/x-twitter` 0 errors 0 warnings, `git diff --check`
clean, review-checks PASS.

NOT addressed here, deliberately: qingyun's second P1 (the manifest/config
contract — declaring X_BROWSER_PROFILE et al. and resolving the profile through
the canonical workspace helper instead of `homedir()/.sutando/...`) and her
live-path evidence gate. Both are real; folding a config refactor into a
destructive-bug fix is the bundled-concern commit CONTRIBUTING rejects, so they
follow separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkcAxdb5YmqrhNb7sLor7H
Stand: Echo Act IV Pro

* fix(x-twitter): declare the skill's settings and derive the profile from the workspace

qingyun P1 #2 on #2133. The manifest declared no config while production read
`X_BROWSER_PROFILE`, `X_LOGIN_DONE_SENTINEL` and `X_LOGIN_TIMEOUT_ITERS` directly
as ad-hoc env vars, and the durable profile default was rebuilt inline as
`homedir()/.sutando/x-browser-profile` instead of the canonical workspace helper.
CLAUDE.md makes both of those rules, not preferences.

  * manifest.json now declares all three under `config` (setdefault semantics, so
    an operator override still wins) plus access_tier/enabled/permissions. SKILL.md
    marks the sentinel and the iteration cap as test/CI controls — declared so they
    are discoverable, not because anyone should set them by hand.
  * the profile derives from `<workspace>/data/x-browser-profile` via
    `scripts/sutando-config.sh workspace`. `data/` is per-user mutable state under
    the workspace contract and is in the vault `exclude` list — this directory holds
    live session cookies and must never sync anywhere.

MIGRATION, because changing this default silently is a real regression: a profile
still sitting at the pre-#2133 location is USED, with a one-line notice naming both
paths, until the operator moves it. A fresh X sign-in is the one genuinely expensive
step in this skill, and there is a live profile in that location on at least one
host today. Same reader-fallback-with-warning shape the workspace migration uses.

Precedence lives in `skills/x-twitter/profile-dir.mjs` with injected dependencies,
not inline in the script. That is deliberate: my first pass verified the behaviour
with a probe that RE-IMPLEMENTED the precedence — the exact mirror-test failure I
argued against one commit earlier on this branch, and it would have gone green
against any drift. Extracting it makes the claim checkable.

+10 checks in `tests/x-post-profile-dir.test.mjs`, importing the production resolver:
override wins; fresh host derives the canonical path; an existing legacy profile is
still used AND the notice names both paths; canonical wins once it exists; an
unresolvable workspace degrades loudly with the escape hatch named.

Checks: `node --test 'tests/**/*.test.mjs'` 3/3 · `lint-skill.py --strict` 0/0 (it
caught an invalid `permissions.secrets` value on the first attempt) ·
`lint-sutando-home-path.sh --diff` clean · review-checks PASS · `git diff --check`
clean · `node --check` on all three modules.

Still open: qingyun's live-path evidence gate (owner-run login → check → dry-run
transcript). Not claimed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkcAxdb5YmqrhNb7sLor7H
Stand: Echo Act IV Pro

* fix(x-twitter): the declared config block is inert outside the voice agent

Follow-through on qingyun blocker 2. cb76a24 added `config` to manifest.json
for the three settings this skill reads. That is honored in exactly ONE
invocation path, and silently ignored in the ones SKILL.md documents.

`loadSkillManifestTools()` (src/inline-tools.ts:1060) copies `config` into
`process.env` at voice-agent startup — for config-only manifests too, since the
loop precedes the `if (!manifest.tools) continue`. So a child process spawned by
the voice agent inherits the declared values.

Nothing else does. Invoked from a shell (as SKILL.md documents), from cron, or
from the agent's Bash tool, this script is not a descendant of that process and
inherits nothing — and no code in the skill reads the manifest: every occurrence
of "manifest.json" in skills/x-twitter is a COMMENT, including the one in
profile-dir.mjs's own precedence docstring. In those paths an operator editing a
declared value gets silence.

It reads as working because the declared values equal the hardcoded literals, so
the two paths agree today. They diverge the moment anyone edits the manifest.

skills/MANIFEST.md names this exact case — "a pipeline script that runs outside
the voice-agent process, so it doesn't inherit the loader's process.env" — and
prescribes reading the manifest directly:

    CLI arg > env override > manifest.json config[key] > built-in default

Adds manifest-config.mjs (deps injected, same rationale as profile-dir.mjs) and
routes all three settings through it, so every invocation path resolves
identically. A missing or malformed manifest yields {} rather than throwing: a
config typo must not be able to break the publish path.

No behavior change today — X_BROWSER_PROFILE ships empty (still falls through to
the derived per-workspace path) and the other two equal their literals. This makes
the declared values load-bearing in every path rather than only under the agent.

tests/x-post-manifest-config.test.mjs — 15 checks, importing the production
resolver. Each precedence rung, empty-means-unset at every rung, missing /
malformed / config-less / non-object manifests, no-secrets, and two drift guards
that re-derive the key list FROM the shipped script and manifest, so a setting
added to either side without the other fails here.

Controls run, not asserted:
  restore the pre-fix call sites -> 3 fail, incl. "declared but inert:
      X_BROWSER_PROFILE, X_LOGIN_DONE_SENTINEL, X_LOGIN_TIMEOUT_ITERS"
  disable ONLY the manifest rung -> exactly the 2 rung-dependent checks fail
  declare a 4th setting nothing
    reads                        -> guard names X_SHOT_DIR
  restored                       -> 15/15, and all 4 .mjs suites green

Collection verified: `node --test 'tests/**/*.test.mjs'` picks the file up, so
CI runs it. eslint could not run in this worktree (no node_modules) — it fails
identically on untouched files.

Stand: Echo Act IV Pro

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(x-twitter): a space-suffix profile could be killed as ours

lineHoldsProfile treated "--user-data-dir=/tmp/x-profile copy" as our profile
followed by an argument, so releaseProfileLock would SIGTERM/SIGKILL an unrelated
browser. Reviewer's exact repro at the parent head:

  lineHoldsProfile('4242 chrome --user-data-dir=/tmp/x-profile copy',
                   '/tmp/x-profile') === true

pgrep -fl flattens argv, so "<dir> copy" is equally one path containing a space or
two arguments — undecidable from the line alone. The discriminator is what follows
the space: a flag proves the path ended there; a bare token does not. Anything that
stays ambiguous now returns false, which is this module's documented failure
direction — a visible lock conflict instead of someone else's browser dying.

Deliberately NOT a blanket fail-closed on any trailing space: that is the common
case ("--user-data-dir=/tmp/x-profile --no-first-run") and would silently disable
the cleanup entirely.

Regression proven against its own broken state:
  with fix        pass 1 / fail 0
  predicate reverted  fail 1 — "SPACE-SUFFIX profile is NOT matched"

node --test tests/**: 4/4. Python login sentinel: exit 0. node --check clean.
review-checks PASS on this diff. (Whole-branch diff also reports a launchd plist
violation — that file is not in the PR diff and the branch is simply behind main;
update-branch clears it.)

The live-path gate on this PR is untouched and remains owner-run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Stand: Echo Act IV Pro

* fix(x-twitter): a bare token after a space is not an argument boundary

Stand: PRO

* fix(x-twitter): decide profile ownership by open descriptor, not by flattened argv

qingyun-wu's P1: `pgrep -fl` has already flattened argv, so
`--user-data-dir=/tmp/x-profile --copy` is equally the single path
`/tmp/x-profile --copy`. The predicate treated a following `--` as proof of an
argument boundary, returned true, and releaseProfileLock() SIGTERM/SIGKILLed an
unrelated browser. No flattened token can prove where a path argument ended.

Take his first option — an argv-preserving source. `lsof -w -F pn +D <profile>`
scopes the search to the directory, so the kernel answers "who holds this
profile" and nothing is parsed out of a path. Intersected with the
Chrome-for-Testing pid set (helpers excluded) so we can only ever signal a GCfT
browser. `-F` field output, not columns: profile paths contain spaces, which is
the whole hazard.

The `--` branch is gone rather than narrowed. lineHoldsProfile() now answers
only on an exact value and is explicitly NOT the production decider; when lsof
finds nothing we kill nothing, because a predicate that cannot decide the
ordinary launch shape is pretend-safety in front of a SIGKILL.

lsof EXITS 1 EVEN WHEN IT PRINTS HOLDERS. The first draft discarded
execFileSync's e.stdout, so the new decider silently returned "nobody holds
this profile" on every run — safe direction, feature dead. Unit tests could not
see it; only a positive control could, by failing to fire.

Live control, production invocation, both polarities:
  sibling process holds "<box>/prof --copy/held.txt"
  +D "<box>/prof"          -> []          the unrelated holder is NOT claimed
  +D "<box>/prof --copy"   -> ["54088"]   control fires, so [] is a real absence

Tests: four assertions that encoded the unsound boundary rule now assert the
opposite, with qingyun's exact `<dir> --copy` case added as a permanent control;
new coverage for the lsof field parser and the GCfT pid set. Mutation-tested —
re-adding the `--` branch reds 5 checks including his case by name; counting a
p-block with no open file reds exactly its own control. 4 mjs suites green.

Timing measured before committing to it: `+D` is 0.58s on this 500MB profile and
0.90s on a live 5.4GB Chrome profile, on a path that already sleeps 1s.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(x-twitter): drop the dead initializers eslint flagged

`no-useless-assignment` on both `let pgrepOut = ""` and `let holders = []` —
each is overwritten by the first statement of its own try, and every path
(try, catch, early return) assigns before any read, so the initial values were
never observable. Declared without them.

  152:7  error  The value assigned to 'pgrepOut' is not used in subsequent statements
  162:7  error  The value assigned to 'holders' is not used in subsequent statements

x-post-profile-match: PASS. No behaviour change.
Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(x-twitter): fail closed when the profile-lock probe is inconclusive

pidsForProfile() collapsed two unrelated cases to the same empty PID
array: "lsof ran and confirmed zero holders" and "lsof failed
inconclusively" (permission denied, missing binary, timeout,
unusable stdout). releaseProfileLock() then unconditionally deleted
SingletonLock/SingletonCookie/SingletonSocket on either outcome, so
an inconclusive probe could let a second Chrome launch against a
still-active profile and corrupt its session state.

pidsForProfile() now returns {known, pids}. A new pure
classifyLsofProbe() in profile-match.mjs makes the tri-state
decision testable without spawning lsof: known:false covers
permission errors, a missing binary, and our own execFileSync
timeout (bounded at 5s, since +D recurses the profile tree);
known:true covers both a clean empty result and the documented
exit-1-with-real-output shape. releaseProfileLock() takes a probe
AFTER its SIGKILL pass and only removes the singleton files when
that probe is known and empty — unknown, or a holder that survived
the kill, both leave the lock in place. That's the same fail-closed
direction this file's own argv-match predicate already documents:
a false negative is a visible, recoverable "already in use" error
on next launch; the alternative is destroying an unrelated session.

Addresses qingyun-wu's review on #2133 (2026-08-24T04:24:02Z).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(x-twitter): execFileSync's timeout error carries no `killed`, so the probe never failed closed

`pidsForProfile()` classified a failed lsof probe with `killed: !!(e && e.killed)`,
and the comment beside it claimed that flag marks our own timeout. It does not.
`killed` is a property of the ASYNC `exec` error; `execFileSync` reports its own
`timeout` kill as `code: 'ETIMEDOUT'` with `signal: 'SIGTERM'` and no `killed`
property at all. Measured on this host's runtime (node v26.0.0):

    node -e "execFileSync('sleep',['5'],{timeout:300})"
      killed  : undefined      own property killed? false
      code    : "ETIMEDOUT"    signal : "SIGTERM"

So on a real timeout the flag read false and `classifyLsofProbe` fell through to
judging the probe by its stdout. A timed-out lsof that had already printed part of
the holder list therefore classified as `known: true` with a TRUNCATED pid set, and
`releaseProfileLock()` acted on it — SIGKILLing from a list nobody could vouch for
and clearing the singleton lock while a holder was still live. That is the same
fail-open shape the `known: false` contract was added to close; the caller was
simply never reaching it.

Adds `execTimedOut()` beside `classifyLsofProbe` and uses it at the call site, so
the timeout branch is a named, unit-testable predicate rather than a boolean built
inline from a property that is absent.

The regression test captures a REAL `execFileSync` timeout instead of a fixture —
a hand-written `{killed: true}` fixture is exactly the belief that produced the bug,
and it passes against the broken code. Controls both ways: with the fix reverted to
`e.killed === true`, two checks fail ("execTimedOut reads TRUE on it", "the same
probe classifies as UNKNOWN"); restored, 16/16 pass.

Closes the [P1] from @qingyun-wu's 2026-08-24 review.

Stand: Sutando-Pro

* fix(x-twitter): a probe called with no arguments measured nothing, so say so

`classifyLsofProbe()` destructured `{ threw, killed, stdout } = {}`, so calling it
with no arguments — or with null, or a non-object — fell through every guard and
returned `{ known: true, pids: [] }`: a confident "zero holders" from a probe that
never ran. That is the fail-OPEN corner of a function whose entire contract is
failing closed, and `releaseProfileLock()` acts on `pids: []` by clearing the
singleton lock.

Only reachable through a caller bug, which is precisely the case the tri-state
exists to make unmistakable. The parameter is now read explicitly and a
non-object answers `known: false`.

Control both ways: with the default restored to `outcome = {}`, three checks fail
("fails closed: no arguments / null / a string"); restored, 21/21 pass. The clean
empty probe still reports `known: true` — a real measured zero is not collateral.

Answers hardening note (1) from @john-the-dev's 2026-08-25 review.

Stand: Sutando-Pro

* fix(x-twitter): a bare catch made every pgrep failure "confirmed empty"

Two code asks from Kewei's review. The live witness is separate and still owed.

**Fail closed on non-no-match pgrep errors.** pidsForProfile() caught every
pgrep failure and returned `known: true, pids: []`:

    } catch {
      return { known: true, pids: [] }; // pgrep exits 1 when nothing matches

Only exit 1 means no match. Exit 2 (bad usage), exit 3 (fatal), and ENOENT
(pgrep absent) all landed on the same branch and asserted an empty holder list.
That is exactly the "cannot say" -> "zero holders" collapse the function's OWN
doc block tells callers never to make -- the contract was written for the lsof
probe below and the pgrep arm above it quietly violated it. Since callers fail
closed on `known: false` to avoid releasing the profile lock while a Chrome
still holds it, the wrong branch here reaches a SIGKILL/lock-release decision.

Discriminator measured rather than assumed:

    pgrep -fl zzz-no-such-proc-zzz  -> status 1  (confirmed empty)
    pgrep --bogus-flag              -> status 2  (cannot say -> known:false)

Before this change both produced `known: true, pids: []`.

**Comment cap.** The block below it ran six consecutive `//` lines against the
repo's 2-line limit. Trimmed to the two non-obvious constraints -- why argv
cannot answer the ownership question, and why the walk is bounded. The incident
narrative it carried belongs in the PR body, per the reviewer's note.

    x-post-browser-login-sentinel  PASS
    x-post-profile-match           PASS
    x-post-composer-readback       PASS
    x-post-manifest-config         PASS
    x-post-profile-dir             PASS
    review-checks                  hardcoded-paths + root-artifacts clean
    node --check                   ok

Stand: Echo Act IV Pro

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

live-test-requested A change-request asks for live-test/repro evidence (voice/vision/runtime policy) request-change Has an active CHANGES_REQUESTED review voice Voice / vision / wake runtime

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants