[BUG] SessionStart:startup systemMessage intermittently not rendered in the interactive TUI (deferred hook set dropped on session.id change)

Status Open
Reported on v2.1.278
Maintainer reply None cached
Activity 1 comment · opened Sep 29, 2026

Preflight

Searched existing issues. This is related to #96699 and #96698, but it is a different path:

  • #96699: /clear messages are never shown in the default renderer.
  • #96698: the message is dropped deterministically on resume/fork when additionalContext repeats.
  • This report: on a plain startup, the SessionStart systemMessage is shown only some of the time. The drop follows from how the interactive TUI defers SessionStart hook results; see "Likely cause".

What's Wrong?

In the interactive terminal UI, a SessionStart hook's systemMessage (⎿ SessionStart:startup says: …) appears on screen only intermittently for the same hook and the same output.

  • We measured 4 of 15 interactive starts on 2.1.278 (tmux- and expect-driven). None of the variables we changed correlated with the result: --verbose, prompt text, the trust dialog, the driver, or hook latency (our hook takes 73–102 ms).
  • On the same starts, the hook's additionalContext reached the model every time we checked (3/3).
  • claude -p … --output-format stream-json --include-hook-events always shows the complete hook_response with the systemMessage in it, so the hook output is correct. Only the on-screen display is lost.
  • Emitting on stderr with exit code 2 is not a more reliable alternative. It lands in the same deferred message set (see below) and renders as a hook error.

Today's retest on 2.1.284 happened to render on both starts we tried (startup, then clear). The intermittency is real, but it is not constant.

What Should Happen?

A SessionStart hook's systemMessage should be displayed on every interactive start. If the CLI deliberately discards it, a debug-log line should say so, so hooks can tell "shown" from "discarded".

Steps to Reproduce

  1. Register a SessionStart hook, via plugin or settings.json, that prints:

``json
{"systemMessage": "notice <random>", "hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": "context <random>"}}
``

  1. Start claude interactively about 10 times, e.g. in tmux, using a fresh trusted directory or the same one.
  2. Count the starts that show SessionStart:startup says: notice …. In our runs it was about 1 in 4. On the same starts the model can always repeat the additionalContext token.

Likely cause

This comes from reading the bundled CLI (versions/2.1.284; the same symbols are in 2.1.278). Minified names are approximate.

  • In the TUI, SessionStart results are deferred: deferHookMessages(promise) stores this._pendingHookSet = { run, sessionId: this.session.id }.
  • When the promise resolves, _settleHookSet(h, v) inserts the messages only if all three of these hold: this._pendingHookSet === h && this.session.id === h.sessionId && v.length > 0. Otherwise it does nothing, silently.
  • Three paths clear the set first, all without logging:
  • a session.subscribe(...) callback registered in start() runs _releaseHookSet(pending, true) whenever session.id !== pending.sessionId;
  • awaitPendingHooks(), which the first prompt submission calls, drops the set and returns if session.id has already changed;
  • any second deferHookMessages / releasePendingHookMessages() call.
  • additionalContext reaches the model by a separate path that this set does not gate. That explains why the model always sees the context while the screen line comes and goes.
  • blockingError (exit 2) is pushed into the same array that _settleHookSet gates.
  • The wrapper around the deferred promise holds a real AbortController, and its signal reaches the hook subprocess spawn. When the set is released, a still-running SessionStart hook can be killed mid-run, not just have its output ignored. Slow SessionStart hooks, such as installers, are exposed to this.

We could not find what changes session.id after boot: this.session is assigned once, in the constructor. So we don't know whether the churn happens on every interactive start or only in some cases.

Version

2.1.278 and 2.1.284 (same symbols), macOS, default renderer.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗