openclaw - 💡(How to fix) Fix Track B: system-event-shape consumer-path leakage; propose per-event audience classification

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

Under umbrella #69208 Track B ("Delivery-mirror and consumer-path leakage"), the 2026-04-20 cleanup comment names #39469 as the Track B canonical and scopes the supporting issues (#38061, #33263, #5964) to assistant-message-shape artifacts. PR #69217 is the active Track B fix for that slice — it adds isTranscriptOnlyOpenClawAssistantMessage in src/config/sessions/transcript.ts and wires it into chat.history, sessions.get, and the sessions history HTTP reader.

The same consumer-path boundary also leaks system-event-shape artifacts — enqueueSystemEvent outputs from the exec runtime, cron, and heartbeat flows. These events carry a different object shape (no role, no provider, no model; their fields are text, ts, contextKey, deliveryContext, trusted), so #69217's predicate does not filter them. There is no canonical Track B issue for this shape today, and the fixes currently in flight for specific symptoms are per-surface or per-channel.

This issue is intended as the system-event-shape canonical under Track B, and proposes a per-event audience: 'internal' | 'user-facing' | 'auto' classification as the underlying primitive. The consumer-path filter would follow as a sibling predicate to #69217's, in the same file.

Root Cause

Under umbrella #69208 Track B ("Delivery-mirror and consumer-path leakage"), the 2026-04-20 cleanup comment names #39469 as the Track B canonical and scopes the supporting issues (#38061, #33263, #5964) to assistant-message-shape artifacts. PR #69217 is the active Track B fix for that slice — it adds isTranscriptOnlyOpenClawAssistantMessage in src/config/sessions/transcript.ts and wires it into chat.history, sessions.get, and the sessions history HTTP reader.

The same consumer-path boundary also leaks system-event-shape artifacts — enqueueSystemEvent outputs from the exec runtime, cron, and heartbeat flows. These events carry a different object shape (no role, no provider, no model; their fields are text, ts, contextKey, deliveryContext, trusted), so #69217's predicate does not filter them. There is no canonical Track B issue for this shape today, and the fixes currently in flight for specific symptoms are per-surface or per-channel.

This issue is intended as the system-event-shape canonical under Track B, and proposes a per-event audience: 'internal' | 'user-facing' | 'auto' classification as the underlying primitive. The consumer-path filter would follow as a sibling predicate to #69217's, in the same file.

Fix Action

Fix / Workaround

  1. An internal producer (maybeNotifyOnExit, heartbeat relay, cron) calls enqueueSystemEvent with a SystemEvent payload.
  2. That event is consumed by downstream readers (chat.history, sessions.get, sessions history HTTP, channel dispatchers) that have no way to distinguish "this event was meant for internal agent awareness" from "this event should reach the user."
  3. The event is rendered, persisted, or relayed as if it were user-facing conversational content.
  • #13911 per-channel announce suppression for sub-agents. Would be subsumed by the per-channel defaultCompletionAudience config proposed below.
  • #50398 coding-agent skill should recommend sessions_spawn + sessions_yield over exec --background. Workaround-level; would remain valid advice but the underlying reason exec --background is broken for heartbeat-invisible completion would go away.

3. Per-surface patches are running faster than the underlying problem shrinks

Code Example

type SystemEventAudience = 'internal' | 'user-facing' | 'auto';

type SystemEvent = {
  text: string;
  ts: number;
  contextKey?: string | null;
  deliveryContext?: DeliveryContext;
  trusted?: boolean;
  audience?: SystemEventAudience;
};

---

completionAudience explicit from defaults?                → use it
else session.trigger{'heartbeat','cron','subagent'}?'internal'
else'user-facing'

---

const canRelayToUser = Boolean(
  delivery.channel !== 'none' &&
  delivery.to &&
  visibility.showAlerts &&
  !everyPendingExecEventIsInternal(events)
);

---

export function isInternalAudienceSystemEvent(event: unknown): boolean {
  if (!event || typeof event !== "object" || Array.isArray(event)) {
    return false;
  }
  const typed = event as { audience?: unknown };
  return typed.audience === 'internal';
}

---

{
  channels: {
    matrix: {
      defaultCompletionAudience: "internal"
    }
  }
}
RAW_BUFFERClick to expand / collapse

Summary

Under umbrella #69208 Track B ("Delivery-mirror and consumer-path leakage"), the 2026-04-20 cleanup comment names #39469 as the Track B canonical and scopes the supporting issues (#38061, #33263, #5964) to assistant-message-shape artifacts. PR #69217 is the active Track B fix for that slice — it adds isTranscriptOnlyOpenClawAssistantMessage in src/config/sessions/transcript.ts and wires it into chat.history, sessions.get, and the sessions history HTTP reader.

The same consumer-path boundary also leaks system-event-shape artifacts — enqueueSystemEvent outputs from the exec runtime, cron, and heartbeat flows. These events carry a different object shape (no role, no provider, no model; their fields are text, ts, contextKey, deliveryContext, trusted), so #69217's predicate does not filter them. There is no canonical Track B issue for this shape today, and the fixes currently in flight for specific symptoms are per-surface or per-channel.

This issue is intended as the system-event-shape canonical under Track B, and proposes a per-event audience: 'internal' | 'user-facing' | 'auto' classification as the underlying primitive. The consumer-path filter would follow as a sibling predicate to #69217's, in the same file.

Why This Issue Exists

Several open reports describe system-event text landing in user-visible chat transcripts across multiple channels. The individual reports differ in surface and exact symptom, but the shared pattern is:

  1. An internal producer (maybeNotifyOnExit, heartbeat relay, cron) calls enqueueSystemEvent with a SystemEvent payload.
  2. That event is consumed by downstream readers (chat.history, sessions.get, sessions history HTTP, channel dispatchers) that have no way to distinguish "this event was meant for internal agent awareness" from "this event should reach the user."
  3. The event is rendered, persisted, or relayed as if it were user-facing conversational content.

Existing visibility controls in the runtime (tools.exec.notifyOnExit, visibility.showAlerts, heartbeat.target, suppressToolErrors, ANNOUNCE_SKIP_TOKEN) are each too coarse to express this distinction at event granularity. Prior work that closed similar reports by adding boolean flags (e.g., #22823, #20193) has not held up in practice — #19894 showed that notifyOnExit:false + suppressToolErrors still leaks sub-agent exec failures, which suggests that boolean notify flags are not the right primitive.

The ExecToolDefaults.trigger field already carries 'heartbeat' / 'cron' / caller context through runExecProcess, but maybeNotifyOnExit never reads it. That signal is present in the runtime today but unused for classification.

Related Issues

A. System-event-shape reports (direct)

  • #66460 cron-owned exec completion events relayed to user by heartbeat. The reporter explicitly proposes "mark cron-owned exec completion events so heartbeat consumes them internally instead of relaying" — the narrowest statement of this design.
  • #61990 exec completion events injected as [Queued messages while agent was busy], derailing active conversation on Discord.
  • #65498 heartbeat / exec-completion interrupt drops main-session final reply.
  • #65994 exec completion events leak as System messages in webchat.
  • #66648 "Exec completed" notifications leak into unrelated webchat sessions.
  • #66748 subagent background exec triggers spurious heartbeat wake-ups on main.
  • #67527 async command completion shown in user message bubbles.
  • #68508 system event message — including the "Handle the result internally. Do not relay it to the user unless explicitly requested." directive itself — lands in visible transcript.
  • #68992 Control UI renders async exec system events in visible chat transcript. Partially addressed by #69366 (UI-layer string-match for Control UI only).
  • #59871 async completion events inject verbose agent instructions into session transcript in TUI.
  • #62418 internal exec notifications surface in user-visible webchat Control UI.

B. Cross-cuts assistant-message-shape (addressed by #69217)

  • #66814 reports both system-event text and heartbeat-followup assistant-message leakage. The bundle-inspection analysis in that issue enumerates bash-tools.exec-runtime-*, heartbeat-runner-*, session-system-events-*, sessions-history-http-* as leak paths. Some symptoms should be covered by #69217 once it lands; residual system-event text is this issue's responsibility. Worth re-reading with the reporter after both changes are in place.

C. Cross-cuts Track A (duplicate persistence — canonical #66443)

  • #56489 system events and cron payloads appear as user messages in webchat.
  • #43168 heartbeat wakeups persisted as synthetic user messages in the main dashboard session.
  • #69037 async exec completion re-injects original user message, causing duplicate agent replies.

These overlap with Track A at the persistence layer. An event-level audience classification would prevent persistence for internal events, which partially addresses them from this direction; the Track A idempotency work covers the remainder.

Adjacent (not addressed here)

  • #66487, #66382 heartbeat relay drops payload and emits generic fallback.
  • #67030 heartbeat relays failed exec notifications with wrong persona/language.
  • #46798 heartbeat re-triggers on local exec completed events, spams duplicates.
  • #62294 non-interval wake reasons bypass interval enforcement.
  • #69329 exec runtime artifact-gated closure seam — orthogonal axis but touches bash-tools.exec-runtime.ts.

Overlapping feature requests

  • #13911 per-channel announce suppression for sub-agents. Would be subsumed by the per-channel defaultCompletionAudience config proposed below.
  • #50398 coding-agent skill should recommend sessions_spawn + sessions_yield over exec --background. Workaround-level; would remain valid advice but the underlying reason exec --background is broken for heartbeat-invisible completion would go away.

Prior attempts at this problem class (closed)

  • #19894 sub-agent exec failures still leak despite notifyOnExit:false + suppressToolErrors.
  • #20193 Node exec events ignore tools.exec.notifyOnExit.
  • #22823 added tools.nodes.notifyOnExit.
  • #41406 notifyOnExit wake reason doesn't bypass empty HEARTBEAT.md guard.
  • #8997 background exec completion notifications interrupt active agent turn.

Related PRs

In-flight Track B slices

  • #69217 — assistant-message-shape consumer-path filter (isTranscriptOnlyOpenClawAssistantMessage). Track B canonical-adjacent; this issue proposes a system-event-shape peer predicate in the same file.
  • #69366 — UI-layer string-pattern filter (EXEC_INJECTION_PREFIX_RE) for System (untrusted): exec messages and HEARTBEAT_OK acks. Closes #68992 for Control UI. The author explicitly notes "fix is UI display-layer only, does not affect channel delivery" and "Did NOT verify: non-webchat channels" — so the same system events still leak on every non-Control-UI surface. Useful defense-in-depth; not a substitute for a backend fix.
  • #69084 — webchat heartbeat filter corrected to use visibility.showAlerts instead of showOk. Scoped to one misreferenced flag; does not change the abstraction.

Historical precedent

  • #22823 (closed, merged) — tools.nodes.notifyOnExit flag.
  • #17340 (closed, merged) — duplicate-transcript fix for followup queue.
  • #40716 (open) — consumer-path filtering for delivery-mirror leakage (Track B precedent per umbrella cleanup).

Current Working Hypotheses

1. Classification gap at the event layer

SystemEvent (src/infra/system-events.ts) has text, ts, contextKey, deliveryContext, trusted — no field that expresses the producer's intent about whether the event should reach the user. deliveryContext routes where an event goes; it does not say whether the event should appear on any user-facing surface.

ExecToolDefaults.trigger (src/agents/bash-tools.exec-types.ts:12) already carries 'heartbeat' / 'cron' / caller context through to the runtime. maybeNotifyOnExit in src/agents/bash-tools.exec-runtime.ts:279–308 does not consult it when deciding what to emit. The signal is present, unused.

2. Consumer-path write-through to visible transcripts

buildExecEventPrompt({ deliverToUser: false }) in src/infra/heartbeat-events-filter.ts:41–54 produces text that literally says "Handle the result internally. Do not relay it to the user unless explicitly requested." #68508 shows this exact directive text landing in visible chat transcripts, which suggests the filter is happening at the agent-instruction level but not at the transcript-persistence level. The pattern #69217 establishes for assistant-message-shape artifacts — a shared predicate called at consumer-path boundaries — applies directly to the system-event shape as well.

3. Per-surface patches are running faster than the underlying problem shrinks

The three Track B PRs filed in the last 48 hours each address a different slice (artifact shape, one UI surface, one misreferenced flag). They collectively close roughly one of the open reports in the system-event cluster. The remaining reports span Matrix, Discord, Telegram, Teams, Mattermost, TUI, and webchat; without an event-level primitive, each new surface needs its own filter and each format change (timezone prefix, text wording, output length) re-opens existing filters. An event-level audience field is the layer at which this can be closed consistently.

4. Boolean notify flags are structurally insufficient

Closed-issue precedent #19894 showed that notifyOnExit:false + suppressToolErrors compositions still leak sub-agent exec failures. Adding more top-level booleans has not held. An enum field on the event itself is a different concept, not another boolean.

Proposed Shape

This is a proposal, not a committed design. Happy to revise per maintainer preference on any of these.

Core primitive

Extend SystemEvent with an optional audience field:

type SystemEventAudience = 'internal' | 'user-facing' | 'auto';

type SystemEvent = {
  text: string;
  ts: number;
  contextKey?: string | null;
  deliveryContext?: DeliveryContext;
  trusted?: boolean;
  audience?: SystemEventAudience;
};

Default is 'auto'. No existing call site has to change.

  • 'user-facing' — reach the user when routing and visibility allow.
  • 'internal' — agent/session/log awareness only. Heartbeat may read; agent may reason; appears in session state. Does not flow to user-facing chat surfaces. Not written to chat.history as a visible entry.
  • 'auto' — defer to downstream (current behavior).

Spawn-time intent

Extend ExecToolDefaults and ProcessSession with completionAudience?: SystemEventAudience. Derive at maybeNotifyOnExit:

completionAudience explicit from defaults?                → use it
else session.trigger ∈ {'heartbeat','cron','subagent'}?    → 'internal'
else                                                       → 'user-facing'

This activates the existing-but-unused trigger field. Matches #66460's reporter's explicit fix proposal, generalized beyond cron.

Relay-time honoring

In src/infra/heartbeat-runner.ts (canRelayToUser computation around line 812):

const canRelayToUser = Boolean(
  delivery.channel !== 'none' &&
  delivery.to &&
  visibility.showAlerts &&
  !everyPendingExecEventIsInternal(events)
);

buildExecEventPrompt({ deliverToUser }) already handles the downstream wording and does not need to change.

Consumer-path filter (sibling to #69217)

Peer predicate in src/config/sessions/transcript.ts, alongside isTranscriptOnlyOpenClawAssistantMessage:

export function isInternalAudienceSystemEvent(event: unknown): boolean {
  if (!event || typeof event !== "object" || Array.isArray(event)) {
    return false;
  }
  const typed = event as { audience?: unknown };
  return typed.audience === 'internal';
}

Wired into the same consumer-path boundaries #69217 touches. The exact call-site shape — two peer predicates called per consumer, or one combined dispatcher that inspects entry shape — is a maintainer preference. Happy to match whatever #69217 settles on.

Config fallback

Optional per-channel default for unspecified events:

{
  channels: {
    matrix: {
      defaultCompletionAudience: "internal"
    }
  }
}

Backward-compat: when unset, behavior collapses to current visibility.showAlerts semantics. Superset, not replacement. Subsumes #13911.

What this does not change

  • notifyOnExit: false still short-circuits before any event is emitted.
  • visibility.showAlerts: false still mutes per channel.
  • ANNOUNCE_SKIP_TOKEN for sessions.send is orthogonal and stays.
  • #69217's isTranscriptOnlyOpenClawAssistantMessage is untouched — peer, not replacement.
  • #69366's UI filter stays as defense-in-depth. After the backend gate is in place, it should rarely match anything the backend hasn't already filtered, but the pattern-match is cheap insurance against future write paths that skip the gate.
  • #69084's showAlerts correction is complementary.

What This Issue Should Track

  1. Whether the Track B scope should be expanded to include system-event-shape artifacts, or whether this should be treated as a separate track.
  2. Whether the per-event audience primitive is the right direction, versus alternatives such as extending per-channel showAlerts to be per-trigger, or extending trusted to be a tri-state visibility field.
  3. Naming — audience vs visibility on the event; completionAudience vs completionVisibility on defaults. Draft uses audience to reduce conflation with the existing visibility.showAlerts channel-level knob.
  4. Consumer-gate shape — two peer predicates or a single combined dispatcher.
  5. Ordering relative to #69217 — waiting for it to land and layering on top, parallel branches, or expanding #69217's scope.

Suggested Exit Criteria

This issue should not close until:

  1. SystemEvent carries an audience field and the exec runtime derives a value from trigger context where available.
  2. Heartbeat relay consults event-level audience in addition to per-channel visibility.
  3. Consumer-path readers gate on audience in the same boundaries #69217 gates for assistant-message-shape artifacts.
  4. At least #66460, #68508, and the system-event portion of #66814 are verified fixed by reporters.

Notes On Issue Closure

Open issues in the list above with unique reproductions or unique affected surfaces should stay open until their exact symptom is verified fixed. This issue is meant to consolidate analysis and design, not to auto-close linked issues.

If #69366 merges and closes #68992 for Control UI only, non-Control-UI reports of the same symptom should stay open and be tracked here until the backend fix lands.


Happy to contribute an implementation PR along these lines if the direction is accepted, following the same iterative bot-review cadence as #67508.

AI-assisted research: cross-indexed open and closed issues, traced the event pipeline in 2026.4.19-beta.2, and drafted this issue with Claude Code (Claude Opus 4.7, 1M context). Fully reviewed.

extent analysis

TL;DR

The proposed fix involves adding an audience field to the SystemEvent type to classify events as 'internal', 'user-facing', or 'auto', and implementing a consumer-path filter to gate events based on this field.

Guidance

  • Extend the SystemEvent type with an optional audience field to express the producer's intent about whether the event should reach the user.
  • Derive the audience value at maybeNotifyOnExit based on the trigger field in ExecToolDefaults and ProcessSession.
  • Implement a consumer-path filter to gate events based on the audience field, similar to the filter in #69217 for assistant-message-shape artifacts.
  • Update the canRelayToUser computation in src/infra/heartbeat-runner.ts to consider the event-level audience field.
  • Introduce a config fallback for unspecified events, allowing per-channel defaults for defaultCompletionAudience.

Example

type SystemEventAudience = 'internal' | 'user-facing' | 'auto';

type SystemEvent = {
  text: string;
  ts: number;
  contextKey?: string | null;
  deliveryContext?: DeliveryContext;
  trusted?: boolean;
  audience?: SystemEventAudience;
};

export function isInternalAudienceSystemEvent(event: unknown): boolean {
  // implementation of the consumer-path filter
}

Notes

The proposed solution aims to address the issue of system-event-shape artifacts leaking into user-visible chat transcripts. The introduction of the audience field and the consumer-path filter should help prevent internal events from being relayed to users. However, the implementation details and the exact shape of the filter may require further discussion and refinement.

Recommendation

Apply the proposed workaround by adding the audience field to the SystemEvent type and implementing the consumer-path filter. This should help mitigate the issue of system-event-shape artifacts leaking into user-visible chat transcripts.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING