atm_core/lifecycle.rs
1//! Vendor-neutral agent lifecycle events.
2//!
3//! `LifecycleEvent` is the abstraction that crosses the daemon boundary —
4//! every vendor adapter (Claude Code, pi, future) translates its native
5//! event vocabulary into this enum. The daemon's session state machine
6//! only ever pattern-matches on `LifecycleEvent`, never on a raw vendor
7//! event type.
8//!
9//! ## Design
10//!
11//! Three properties drove the shape:
12//!
13//! 1. **No vendor escape hatch.** Earlier drafts had a `VendorSpecific`
14//! variant; that turned out to leak vendor knowledge into every
15//! consumer. Instead, every event maps to a generic concept (e.g.
16//! Claude `SubagentStart` → `ChildSessionStart`).
17//! 2. **`NeedsInput` is adapter-synthesized, not raw.** Pi has no
18//! permission-prompt event — its extension synthesizes one inside a
19//! `tool_call` handler. Claude only signals it via `PreToolUse` of an
20//! interactive tool. So this variant is emitted by the translation
21//! layer, never directly by a vendor.
22//! 3. **Provider/model are session metadata, not per-event.** Pi is
23//! provider-agnostic — one session can switch from Anthropic to
24//! OpenAI mid-stream — so changes ride a dedicated
25//! `ProviderModelChange` variant rather than annotating every event.
26//!
27//! Tool identity rides the typed `Tool` enum instead of free strings,
28//! so the well-known set is defined once and the open tail of MCP /
29//! vendor-specific names lives in `Tool::Other(String)`.
30
31use crate::Tool;
32use serde::{Deserialize, Serialize};
33
34/// Sub-kind of a notification, for the cases the daemon special-cases.
35///
36/// Pi doesn't use these strings — its permission gating is extension-
37/// mediated and surfaces via `NeedsInputReason::PermissionGate`. The
38/// known variants here are Claude `Notification(notification_type)`
39/// values plus the `setup` synthetic kind we emit when translating
40/// Claude's one-time `Setup` hook event.
41#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(into = "String", from = "String")]
43pub enum NotificationKind {
44 /// Claude permission prompt — agent is waiting on a yes/no.
45 PermissionPrompt,
46 /// Claude MCP elicitation — extension wants structured input.
47 ElicitationDialog,
48 /// Claude idle prompt — agent has gone idle.
49 IdlePrompt,
50 /// Claude one-time `Setup` hook (renamed from raw event).
51 Setup,
52 /// Generic informational notification.
53 Info,
54 /// Any other notification kind (vendor-specific, future kinds).
55 Other(String),
56}
57
58impl NotificationKind {
59 /// Wire-format string for this kind.
60 #[must_use]
61 pub fn as_str(&self) -> &str {
62 match self {
63 Self::PermissionPrompt => "permission_prompt",
64 Self::ElicitationDialog => "elicitation_dialog",
65 Self::IdlePrompt => "idle_prompt",
66 Self::Setup => "setup",
67 Self::Info => "info",
68 Self::Other(s) => s.as_str(),
69 }
70 }
71}
72
73impl std::fmt::Display for NotificationKind {
74 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 f.write_str(self.as_str())
76 }
77}
78
79impl NotificationKind {
80 /// Canonical lookup for known variants. Returns `None` for inputs
81 /// that should fall through to `Other(_)`.
82 fn try_from_known(s: &str) -> Option<Self> {
83 Some(match s {
84 "permission_prompt" => Self::PermissionPrompt,
85 "elicitation_dialog" => Self::ElicitationDialog,
86 "idle_prompt" => Self::IdlePrompt,
87 "setup" => Self::Setup,
88 "info" => Self::Info,
89 _ => return None,
90 })
91 }
92}
93
94impl From<&str> for NotificationKind {
95 fn from(s: &str) -> Self {
96 Self::try_from_known(s).unwrap_or_else(|| Self::Other(s.to_string()))
97 }
98}
99
100impl From<String> for NotificationKind {
101 fn from(s: String) -> Self {
102 // Reuse the owned String on the Other path to avoid re-allocation.
103 Self::try_from_known(&s).unwrap_or(Self::Other(s))
104 }
105}
106
107impl From<NotificationKind> for String {
108 fn from(k: NotificationKind) -> Self {
109 match k {
110 NotificationKind::Other(s) => s,
111 other => other.as_str().to_string(),
112 }
113 }
114}
115
116/// Why a session is awaiting user input.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(tag = "reason", rename_all = "snake_case")]
119pub enum NeedsInputReason {
120 /// Claude `PreToolUse` for an interactive tool
121 /// (`AskUserQuestion`, `EnterPlanMode`, `ExitPlanMode`).
122 InteractiveTool { tool: Tool },
123 /// Pi extension-mediated `tool_call` permission gate.
124 PermissionGate { tool: Tool },
125 /// Generic notification-driven prompt
126 /// (Claude `Notification(permission_prompt|elicitation_dialog)`,
127 /// pi `atm_needs_input_open`). `label` carries an optional
128 /// vendor-supplied human string (e.g. the dialog title or the
129 /// command being gated) so the TUI can show *what* is being
130 /// asked, not just that *something* is.
131 Notification {
132 kind: NotificationKind,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 label: Option<String>,
135 },
136}
137
138/// Vendor-neutral lifecycle event.
139///
140/// Adapters translate native vendor events into these variants; the
141/// daemon dispatches on them to update session state.
142///
143/// `Eq` is intentionally not derived: `ContextUpdate` carries an
144/// `Option<f64>` cost, which is not `Eq`.
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146#[serde(tag = "type", rename_all = "snake_case")]
147pub enum LifecycleEvent {
148 /// Session opened. `source` carries why (Claude: `startup`/`resume`/
149 /// `clear`; pi: `startup`).
150 SessionStart { source: Option<String> },
151
152 /// Session closed. `reason` is vendor-supplied when available
153 /// (pi `session_shutdown.reason`; Claude `SessionEnd.reason`).
154 SessionEnd { reason: Option<String> },
155
156 /// Agent began a working block.
157 /// (Pi `agent_start`; Claude has no direct analog — synthesized
158 /// from `UserPromptSubmit` or first `PreToolUse`.)
159 WorkingStart,
160
161 /// Agent finished a working block.
162 /// (Pi `agent_end`; Claude `Stop`.)
163 WorkingEnd,
164
165 /// Explicit idle signal. Distinct from `WorkingEnd` because pi can
166 /// transition idle → idle (no work happened) via
167 /// `ctx.isIdle() && !ctx.hasPendingMessages()`.
168 Idle,
169
170 /// User submitted a prompt.
171 PromptSubmit { prompt: Option<String> },
172
173 /// Session is waiting on user input.
174 NeedsInput { reason: NeedsInputReason },
175
176 /// A tool started executing.
177 ///
178 /// `tool_use_id` is the correlation id pairing this with its later
179 /// `ToolCallEnd` (Claude `tool_use_id`, pi `toolCallId`). `input`
180 /// is the tool arguments JSON, when the vendor exposes it.
181 ToolCallStart {
182 name: Tool,
183 tool_use_id: Option<String>,
184 input: Option<serde_json::Value>,
185 },
186
187 /// A tool finished executing. `tool_use_id` matches the originating
188 /// `ToolCallStart`.
189 ToolCallEnd {
190 name: Tool,
191 tool_use_id: Option<String>,
192 is_error: bool,
193 },
194
195 /// Context compaction is starting. `trigger` is the cause
196 /// (Claude: `auto`/`manual`).
197 ContextCompactStart { trigger: Option<String> },
198
199 /// Periodic context-usage update (tokens used, accumulated cost).
200 /// Either field may be `None` if the vendor doesn't expose it.
201 ContextUpdate {
202 tokens: Option<u64>,
203 cost_usd: Option<f64>,
204 },
205
206 /// Provider or model changed mid-session (pi `model_select`).
207 ProviderModelChange {
208 provider: Option<String>,
209 model: Option<String>,
210 },
211
212 /// Free-form notification surfaced to the user. `kind` carries an
213 /// optional sub-type the UI can render specially.
214 Notification {
215 message: Option<String>,
216 kind: Option<NotificationKind>,
217 },
218
219 /// A child session (subagent / task / fork) started.
220 /// `id` is a vendor-supplied correlation id; `role` is a free-form
221 /// tag (e.g. Claude subagent role: `"explore"`, `"plan"`).
222 ChildSessionStart {
223 id: Option<String>,
224 role: Option<String>,
225 },
226
227 /// A child session finished.
228 ChildSessionEnd { id: Option<String> },
229}
230
231impl LifecycleEvent {
232 /// True if this event ends/clears the session's working state.
233 #[must_use]
234 pub fn is_terminal_for_turn(&self) -> bool {
235 matches!(
236 self,
237 Self::WorkingEnd | Self::Idle | Self::SessionEnd { .. }
238 )
239 }
240
241 /// True if this event opens the session's working state.
242 #[must_use]
243 pub fn is_starting(&self) -> bool {
244 matches!(
245 self,
246 Self::SessionStart { .. } | Self::WorkingStart | Self::PromptSubmit { .. }
247 )
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn lifecycle_event_serde_roundtrip() {
257 let cases = vec![
258 LifecycleEvent::SessionStart {
259 source: Some("startup".into()),
260 },
261 LifecycleEvent::SessionEnd {
262 reason: Some("quit".into()),
263 },
264 LifecycleEvent::WorkingStart,
265 LifecycleEvent::WorkingEnd,
266 LifecycleEvent::Idle,
267 LifecycleEvent::PromptSubmit {
268 prompt: Some("hello".into()),
269 },
270 LifecycleEvent::NeedsInput {
271 reason: NeedsInputReason::InteractiveTool {
272 tool: Tool::AskUserQuestion,
273 },
274 },
275 LifecycleEvent::NeedsInput {
276 reason: NeedsInputReason::PermissionGate { tool: Tool::Bash },
277 },
278 LifecycleEvent::NeedsInput {
279 reason: NeedsInputReason::Notification {
280 kind: NotificationKind::PermissionPrompt,
281 label: None,
282 },
283 },
284 LifecycleEvent::NeedsInput {
285 reason: NeedsInputReason::Notification {
286 kind: NotificationKind::PermissionPrompt,
287 label: Some("rm -rf /tmp".into()),
288 },
289 },
290 LifecycleEvent::ToolCallStart {
291 name: Tool::Bash,
292 tool_use_id: Some("tu_123".into()),
293 input: Some(serde_json::json!({"command": "ls /tmp"})),
294 },
295 LifecycleEvent::ToolCallEnd {
296 name: Tool::Bash,
297 tool_use_id: Some("tu_123".into()),
298 is_error: false,
299 },
300 LifecycleEvent::ContextCompactStart {
301 trigger: Some("auto".into()),
302 },
303 LifecycleEvent::ContextUpdate {
304 tokens: Some(1024),
305 cost_usd: Some(0.05),
306 },
307 LifecycleEvent::ProviderModelChange {
308 provider: Some("openai-codex".into()),
309 model: Some("gpt-5.5".into()),
310 },
311 LifecycleEvent::Notification {
312 message: Some("hi".into()),
313 kind: Some(NotificationKind::Info),
314 },
315 LifecycleEvent::ChildSessionStart {
316 id: Some("agent-1".into()),
317 role: Some("explore".into()),
318 },
319 LifecycleEvent::ChildSessionEnd {
320 id: Some("agent-1".into()),
321 },
322 ];
323
324 for ev in cases {
325 let json = serde_json::to_string(&ev).expect("serialize");
326 let back: LifecycleEvent = serde_json::from_str(&json).expect("deserialize");
327 assert_eq!(ev, back, "roundtrip failed: {json}");
328 }
329 }
330
331 #[test]
332 fn terminal_and_starting_classification() {
333 assert!(LifecycleEvent::WorkingEnd.is_terminal_for_turn());
334 assert!(LifecycleEvent::Idle.is_terminal_for_turn());
335 assert!(LifecycleEvent::SessionEnd { reason: None }.is_terminal_for_turn());
336 assert!(!LifecycleEvent::WorkingStart.is_terminal_for_turn());
337
338 assert!(LifecycleEvent::SessionStart { source: None }.is_starting());
339 assert!(LifecycleEvent::WorkingStart.is_starting());
340 assert!(LifecycleEvent::PromptSubmit { prompt: None }.is_starting());
341 assert!(!LifecycleEvent::WorkingEnd.is_starting());
342 }
343
344 #[test]
345 fn notification_kind_wire_format_known() {
346 assert_eq!(
347 serde_json::to_string(&NotificationKind::PermissionPrompt).unwrap(),
348 "\"permission_prompt\""
349 );
350 assert_eq!(
351 serde_json::from_str::<NotificationKind>("\"permission_prompt\"").unwrap(),
352 NotificationKind::PermissionPrompt
353 );
354 }
355
356 #[test]
357 fn notification_kind_other_passthrough() {
358 let custom = NotificationKind::Other("vendor_specific".into());
359 let json = serde_json::to_string(&custom).unwrap();
360 assert_eq!(json, "\"vendor_specific\"");
361 assert_eq!(
362 serde_json::from_str::<NotificationKind>(&json).unwrap(),
363 custom
364 );
365 }
366}