Summary
The plugin works in E2E tests but never sends a nudge in real interactive (TUI) sessions. The root cause is a combination of two issues: lastUserMessage is never populated in TUI sessions, and the two-phase idle fallback requires a second session.idle event that OpenCode never emits.
Root Cause Analysis
1. chat.message hook does not fire for TUI-originated user messages
index.ts registers a chat.message hook to record lastUserMessage:
"chat.message": (messageInput) => {
handleUserMessage({ sessionID: messageInput.sessionID })
return Promise.resolve()
},
In practice, when a user types into the TUI and presses Enter, OpenCode does not invoke the plugin's chat.message hook. As a result, lastUserMessage stays at 0 for the entire interactive session.
2. The two-phase fallback requires a second session.idle event that never arrives
Because lastUserMessage === 0, idle-handler.ts falls into the two-phase branch:
} else {
if (state.lastIdleSeen === 0) {
state.lastIdleSeen = now // records first idle
return // waits for a second one
}
if (now - state.lastIdleSeen < getIdleThreshold()) return
}
This requires OpenCode to emit a second session.idle event separated by at least IDLE_THRESHOLD (default 5 min) from the first. OpenCode only fires session.idle once per idle transition — no second event ever arrives, so the nudge is never sent.
3. Why E2E passes
The E2E test sets OPENCODE_IDLE_THRESHOLD_MS=0. With a zero threshold, the check now - state.lastIdleSeen < getIdleThreshold() is always false, so the first idle event immediately passes through — regardless of lastUserMessage being unset. The bug is masked entirely by the test's environment override.
Reproduction
- Build and install the plugin normally (
make install && make build-plugin)
- Start an interactive opencode session (TUI)
- Send a message and wait — the AI responds and the session goes idle
- Wait 5+ minutes
- Observe: no nudge is ever injected
Expected Behaviour
After the configured idle threshold elapses following the AI's response, the plugin injects the continuation prompt.
Proposed Fix
Replace the chat.message hook approach with listening to session.status events (already flowing through the existing event handler). A session.status event with status.type === "running" fires reliably every time the AI starts processing a new user message — making it a correct proxy for "user sent a message":
if (event.type === "session.status" && event.properties.status.type === "running") {
handleUserMessage({ sessionID: event.properties.sessionID })
}
This removes the dependency on chat.message firing and makes the single-phase path (lastUserMessage > 0) work correctly in all contexts including the TUI.
Summary
The plugin works in E2E tests but never sends a nudge in real interactive (TUI) sessions. The root cause is a combination of two issues:
lastUserMessageis never populated in TUI sessions, and the two-phase idle fallback requires a secondsession.idleevent that OpenCode never emits.Root Cause Analysis
1.
chat.messagehook does not fire for TUI-originated user messagesindex.tsregisters achat.messagehook to recordlastUserMessage:In practice, when a user types into the TUI and presses Enter, OpenCode does not invoke the plugin's
chat.messagehook. As a result,lastUserMessagestays at0for the entire interactive session.2. The two-phase fallback requires a second
session.idleevent that never arrivesBecause
lastUserMessage === 0,idle-handler.tsfalls into the two-phase branch:This requires OpenCode to emit a second
session.idleevent separated by at leastIDLE_THRESHOLD(default 5 min) from the first. OpenCode only firessession.idleonce per idle transition — no second event ever arrives, so the nudge is never sent.3. Why E2E passes
The E2E test sets
OPENCODE_IDLE_THRESHOLD_MS=0. With a zero threshold, the checknow - state.lastIdleSeen < getIdleThreshold()is always false, so the first idle event immediately passes through — regardless oflastUserMessagebeing unset. The bug is masked entirely by the test's environment override.Reproduction
make install && make build-plugin)Expected Behaviour
After the configured idle threshold elapses following the AI's response, the plugin injects the continuation prompt.
Proposed Fix
Replace the
chat.messagehook approach with listening tosession.statusevents (already flowing through the existingeventhandler). Asession.statusevent withstatus.type === "running"fires reliably every time the AI starts processing a new user message — making it a correct proxy for "user sent a message":This removes the dependency on
chat.messagefiring and makes the single-phase path (lastUserMessage > 0) work correctly in all contexts including the TUI.