Handle a TurnEnd event (Stop). Records an Atomic change for the turn (status → add → record), then transitions the session back to Idle. The recording workflow lets the repository figure out what changed: 1. `repo.status()` — find modified, deleted, and untracked files 2. `repo.add()` — track any new files the agent created 3. `repo.record(all: true)` — record everything that's dirty This avoid
(
&mut self,
mut event: TurnEvent,
)
| 132 | /// snapshots between TurnStart and TurnEnd. Instead, we ask the |
| 133 | /// repository what changed since the last recorded state. |
| 134 | pub(super) async fn handle_turn_end( |
| 135 | &mut self, |
| 136 | mut event: TurnEvent, |
| 137 | ) -> AgentResult<DispatchResult> { |
| 138 | // Clone so `event` stays mutable for enrichment below while the |
| 139 | // id is borrowed throughout this function. |
| 140 | let session_id_owned = event.session_id.clone(); |
| 141 | let session_id = session_id_owned.as_str(); |
| 142 | let _turn_end_lock = match self.try_turn_end_lock(session_id) { |
| 143 | TurnEndLock::Acquired(guard) => Some(guard), |
| 144 | TurnEndLock::Busy => { |
| 145 | log::warn!( |
| 146 | "Turn end for session {} is already being recorded; skipping duplicate Stop hook", |
| 147 | session_id |
| 148 | ); |
| 149 | return Ok(DispatchResult::new(session_id, phase::Phase::Idle) |
| 150 | .with_warning("duplicate Stop hook skipped: turn already recording")); |
| 151 | } |
| 152 | TurnEndLock::Unavailable => None, |
| 153 | }; |
| 154 | |
| 155 | // Fast gate: check if anything changed since the last record. |
| 156 | // This bypasses the entire status machinery (TREE scan, filesystem |
| 157 | // walk, etc.) and just checks the pristine database mtime. |
| 158 | // If the DB hasn't been written since the last record, nothing |
| 159 | // in the working copy could have been recorded — but files may |
| 160 | // have been edited. We check the working copy for recent mtimes |
| 161 | // by scanning only the repo root (not recursively) and common |
| 162 | // source directories. |
| 163 | if !self.has_working_copy_changes() { |
| 164 | log::info!( |
| 165 | "Turn end for session {} — no changes detected, skipping record", |
| 166 | session_id |
| 167 | ); |
| 168 | return Ok(DispatchResult::new(session_id, phase::Phase::Idle)); |
| 169 | } |
| 170 | |
| 171 | let mut session = self.load_or_create_session(session_id, &event)?; |
| 172 | |
| 173 | // Extract model/provider from the TurnEnd event's raw_json. |
| 174 | // OpenCode sends model and provider in every stop payload. |
| 175 | // This is the last chance to capture the info before recording, |
| 176 | // in case TurnStart didn't have it (e.g., session was created |
| 177 | // outside the plugin, or the chat.message hook didn't fire). |
| 178 | if let Some(ref raw) = event.raw_json { |
| 179 | if let Some(model) = raw.get("model").and_then(|v| v.as_str()) { |
| 180 | if !model.is_empty() { |
| 181 | session.model = model.to_string(); |
| 182 | } |
| 183 | } |
| 184 | if let Some(provider) = raw.get("provider").and_then(|v| v.as_str()) { |
| 185 | if !provider.is_empty() { |
| 186 | session.agent_vendor = provider.to_string(); |
| 187 | } |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | // Update the transcript path from the Stop event. Claude Code's Stop |
no test coverage detected