Skip to main content

navi_core/
event.rs

1use crate::capability::CapabilityLedgerEntry;
2use crate::goal::types::GoalStatus;
3use crate::patch::PatchProposal;
4use crate::tool::{ToolInvocation, ToolResult};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8/// One display item in a transient subagent transcript.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct SubagentTranscriptItem {
11    /// Kind of transcript item.
12    pub kind: SubagentTranscriptKind,
13    /// Main one-line item text.
14    pub title: String,
15    /// Optional secondary text, already compacted for UI display.
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub detail: Option<String>,
18    /// Optional success state for completed work.
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub ok: Option<bool>,
21}
22
23/// Display item kind for a transient subagent transcript.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25pub enum SubagentTranscriptKind {
26    ToolRequested,
27    ToolCompleted,
28    Text,
29}
30
31/// A versioned runtime event emitted during agent execution.
32///
33/// Wraps a [`RuntimeEventKind`] with a schema version so consumers can handle
34/// forward-compatible event streams.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct RuntimeEvent {
37    /// Event schema version. Currently `1`.
38    #[serde(default)]
39    pub version: u32,
40    /// The specific event payload.
41    pub kind: RuntimeEventKind,
42}
43
44impl RuntimeEvent {
45    /// Creates a new event with version 1.
46    pub fn new(kind: RuntimeEventKind) -> Self {
47        Self { version: 1, kind }
48    }
49
50    /// Converts this event into an [`AgentEvent`] if the kind maps to one.
51    ///
52    /// Lifecycle-only events (session started/saved/finished, turn
53    /// started/completed, tool started, context updated) return `None` because
54    /// they have no direct agent-level counterpart.
55    pub fn into_agent_event(self) -> Option<AgentEvent> {
56        self.kind.into_agent_event()
57    }
58}
59
60/// Discriminates the kind of runtime event emitted by the agent loop.
61///
62/// Variants cover the full session lifecycle from start through turn
63/// execution, tool invocation, approval flow, compaction, and error reporting.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub enum RuntimeEventKind {
66    /// A new session has been created.
67    SessionStarted {
68        /// Unique identifier for the session.
69        session_id: String,
70    },
71    /// A new turn within the session has started.
72    TurnStarted {
73        /// Unique identifier for the turn.
74        turn_id: String,
75    },
76    /// A streaming text delta from the assistant.
77    AssistantDelta {
78        /// Incremental text content.
79        text: String,
80    },
81    /// A streaming thinking/reasoning delta from the assistant.
82    AssistantThinkingDelta {
83        /// Incremental thinking content.
84        text: String,
85    },
86    /// The assistant has requested a tool invocation.
87    ToolRequested(ToolInvocation),
88    /// A tool invocation requires user approval before execution.
89    ApprovalRequired(ApprovalRequest),
90    /// An approval request has been resolved (approved or denied).
91    ApprovalResolved(ApprovalDecision),
92    /// A capability lifecycle event was recorded by the policy layer.
93    CapabilityRecorded(CapabilityLedgerEntry),
94    /// The assistant has requested an interactive user choice.
95    QuestionRequired(QuestionRequest),
96    /// An interactive user choice has been resolved.
97    QuestionResolved(QuestionResponse),
98    /// Plan tool create is waiting for user review (blocks the turn).
99    PlanReviewRequired(PlanReviewRequest),
100    /// User finished plan review.
101    PlanReviewResolved(PlanReviewResponse),
102    /// Sudo password needed (no secret in event payload).
103    SudoPasswordRequired(SudoPasswordRequest),
104    /// A tool invocation has begun execution.
105    ToolStarted(ToolInvocation),
106    /// A tool invocation has completed.
107    ToolCompleted(ToolResult),
108    /// A nested subagent emitted a transient UI activity update.
109    SubagentActivity {
110        /// Parent subagent tool invocation id.
111        invocation_id: String,
112        /// Human-readable description of the latest nested activity.
113        message: String,
114    },
115    /// A nested subagent emitted a transient transcript item for UI drill-down.
116    SubagentTranscript {
117        /// Parent subagent tool invocation id.
118        invocation_id: String,
119        /// Transcript item to append for the active UI session.
120        item: SubagentTranscriptItem,
121    },
122    /// A harness-level diagnostic trace (profile, message count, tool count).
123    HarnessTrace(Value),
124    /// The harness stopped a turn before another model iteration.
125    HarnessStopped {
126        /// Machine-readable stop reason.
127        reason: String,
128        /// Human-readable diagnostic.
129        message: String,
130        /// Tool involved in the stop, when applicable.
131        #[serde(default, skip_serializing_if = "Option::is_none")]
132        tool_name: Option<String>,
133    },
134    /// A file patch has been proposed by the assistant.
135    PatchProposed(PatchProposal),
136    /// The conversation context has been updated.
137    ContextUpdated,
138    /// Token usage has been reported by the model provider.
139    TokensUpdated {
140        /// Number of input/prompt tokens consumed.
141        input_tokens: u64,
142        /// Number of output/completion tokens produced.
143        output_tokens: u64,
144        /// Number of tokens written to the prompt cache (Anthropic).
145        cache_creation_tokens: u64,
146        /// Number of tokens read from the prompt cache (Anthropic).
147        cache_read_tokens: u64,
148    },
149    /// The session has been persisted to disk.
150    SessionSaved {
151        /// Identifier of the saved session.
152        session_id: String,
153    },
154    /// Session display title was assigned or updated (provisional or model-named).
155    SessionTitleUpdated {
156        /// Identifier of the session.
157        session_id: String,
158        /// New display title.
159        title: String,
160    },
161    /// A turn has completed with a final text response.
162    TurnCompleted {
163        /// Identifier of the completed turn.
164        turn_id: String,
165        /// Final assistant text for the turn.
166        text: String,
167    },
168    /// The session has ended.
169    SessionFinished {
170        /// Identifier of the finished session.
171        session_id: String,
172    },
173    /// Micro-compaction cleared stale read-only tool results from history.
174    MicroCompactApplied {
175        /// Number of tool result messages that were cleared.
176        messages_cleared: usize,
177    },
178    /// An automatic conversation compaction has started.
179    AutoCompactStarted,
180    /// An automatic conversation compaction has completed.
181    AutoCompactCompleted {
182        /// Estimated number of tokens saved by compaction.
183        tokens_saved: u64,
184    },
185    /// An automatic conversation compaction has failed.
186    AutoCompactFailed {
187        /// Human-readable reason for the failure.
188        reason: String,
189    },
190    /// Auto-dream memory consolidation has started.
191    AutoDreamStarted {
192        /// Hours since the last dream run.
193        hours_since_last: u64,
194        /// Number of sessions reviewed.
195        sessions_reviewed: usize,
196    },
197    /// Auto-dream memory consolidation has completed.
198    AutoDreamCompleted {
199        /// Memories marked stale.
200        marked_stale: usize,
201        /// Duplicates merged.
202        duplicates_merged: usize,
203        /// Active memories remaining.
204        active_count: usize,
205    },
206    /// Auto-dream memory consolidation has failed.
207    AutoDreamFailed {
208        /// Human-readable reason for the failure.
209        reason: String,
210    },
211    /// The agent requested to set a goal via natural language.
212    SetGoalRequested {
213        /// The objective text.
214        objective: String,
215        /// Optional short UI label.
216        short_description: Option<String>,
217        /// Optional token budget.
218        token_budget: Option<i64>,
219    },
220    GoalUpdated {
221        /// The session this goal belongs to.
222        session_id: String,
223        /// Unique identifier for the goal.
224        goal_id: String,
225        /// The objective text.
226        objective: String,
227        /// Optional short UI label.
228        short_description: Option<String>,
229        /// Current goal status.
230        status: GoalStatus,
231        /// Tokens consumed so far.
232        tokens_used: i64,
233        /// Optional token budget.
234        token_budget: Option<i64>,
235    },
236    /// An error occurred during agent execution.
237    Error {
238        /// Human-readable error message.
239        message: String,
240    },
241    /// The agent proposed a plan in Plan mode.
242    /// The UI should show a confirmation popup to implement or discard.
243    PlanProposed {
244        /// The session that proposed the plan.
245        session_id: String,
246        /// Title/summary of the plan.
247        title: String,
248        /// Ordered list of steps.
249        steps: Vec<String>,
250    },
251    /// The agent mode changed (e.g. Default → Plan or Plan → Default).
252    AgentModeChanged {
253        /// The session whose mode changed.
254        session_id: String,
255        /// The new mode.
256        mode: crate::plan_mode::AgentMode,
257    },
258}
259
260impl RuntimeEventKind {
261    /// Converts this event kind into an [`AgentEvent`] if applicable.
262    ///
263    /// Returns `None` for lifecycle-only events that have no direct
264    /// agent-level counterpart (session/turn lifecycle, tool started,
265    /// context updated).
266    pub fn into_agent_event(self) -> Option<AgentEvent> {
267        match self {
268            RuntimeEventKind::AssistantDelta { text } => Some(AgentEvent::ModelDelta { text }),
269            RuntimeEventKind::AssistantThinkingDelta { text } => {
270                Some(AgentEvent::ModelThinkingDelta { text })
271            }
272            RuntimeEventKind::ToolRequested(invocation) => {
273                Some(AgentEvent::ToolRequested(invocation))
274            }
275            RuntimeEventKind::ApprovalRequired(request) => {
276                Some(AgentEvent::ApprovalRequested(request))
277            }
278            RuntimeEventKind::ApprovalResolved(decision) => {
279                Some(AgentEvent::ApprovalResolved(decision))
280            }
281            RuntimeEventKind::CapabilityRecorded(entry) => {
282                Some(AgentEvent::CapabilityRecorded(entry))
283            }
284            RuntimeEventKind::QuestionRequired(request) => {
285                Some(AgentEvent::QuestionRequested(request))
286            }
287            RuntimeEventKind::QuestionResolved(response) => {
288                Some(AgentEvent::QuestionResolved(response))
289            }
290            RuntimeEventKind::PlanReviewRequired(request) => {
291                Some(AgentEvent::PlanReviewRequested(request))
292            }
293            RuntimeEventKind::PlanReviewResolved(response) => {
294                Some(AgentEvent::PlanReviewResolved(response))
295            }
296            RuntimeEventKind::SudoPasswordRequired(request) => {
297                Some(AgentEvent::SudoPasswordRequested(request))
298            }
299            RuntimeEventKind::ToolCompleted(result) => Some(AgentEvent::ToolCompleted(result)),
300            RuntimeEventKind::SubagentActivity {
301                invocation_id,
302                message,
303            } => Some(AgentEvent::SubagentActivity {
304                invocation_id,
305                message,
306            }),
307            RuntimeEventKind::SubagentTranscript {
308                invocation_id,
309                item,
310            } => Some(AgentEvent::SubagentTranscript {
311                invocation_id,
312                item,
313            }),
314            RuntimeEventKind::HarnessTrace(value) => Some(AgentEvent::HarnessTrace(value)),
315            RuntimeEventKind::HarnessStopped {
316                reason,
317                message,
318                tool_name,
319            } => Some(AgentEvent::HarnessStopped {
320                reason,
321                message,
322                tool_name,
323            }),
324            RuntimeEventKind::PatchProposed(patch) => Some(AgentEvent::PatchProposed(patch)),
325            RuntimeEventKind::TokensUpdated {
326                input_tokens,
327                output_tokens,
328                cache_creation_tokens,
329                cache_read_tokens,
330            } => Some(AgentEvent::UsageReported {
331                input_tokens,
332                output_tokens,
333                cache_creation_tokens,
334                cache_read_tokens,
335            }),
336            RuntimeEventKind::MicroCompactApplied { messages_cleared } => {
337                Some(AgentEvent::MicroCompactApplied { messages_cleared })
338            }
339            RuntimeEventKind::AutoCompactStarted => Some(AgentEvent::AutoCompactStarted),
340            RuntimeEventKind::AutoCompactCompleted { tokens_saved } => {
341                Some(AgentEvent::AutoCompactCompleted { tokens_saved })
342            }
343            RuntimeEventKind::AutoCompactFailed { reason } => {
344                Some(AgentEvent::AutoCompactFailed { reason })
345            }
346            RuntimeEventKind::Error { message } => Some(AgentEvent::Error { message }),
347            RuntimeEventKind::SetGoalRequested {
348                objective,
349                short_description,
350                token_budget,
351            } => Some(AgentEvent::SetGoalRequested {
352                objective,
353                short_description,
354                token_budget,
355            }),
356            RuntimeEventKind::GoalUpdated {
357                session_id,
358                goal_id,
359                objective,
360                short_description,
361                status,
362                tokens_used,
363                token_budget,
364            } => Some(AgentEvent::GoalUpdated {
365                session_id,
366                goal_id,
367                objective,
368                short_description,
369                status,
370                tokens_used,
371                token_budget,
372            }),
373            RuntimeEventKind::PlanProposed { title, steps, .. } => {
374                Some(AgentEvent::PlanProposed { title, steps })
375            }
376            RuntimeEventKind::AgentModeChanged { mode, .. } => {
377                Some(AgentEvent::AgentModeChanged { mode })
378            }
379            _ => None,
380        }
381    }
382}
383
384/// A high-level agent event suitable for client consumption.
385///
386/// Unlike [`RuntimeEventKind`], agent events represent the semantic actions
387/// a client cares about: user input, model output, tool calls, approvals,
388/// compaction, usage, and errors.
389#[derive(Debug, Clone, Serialize, Deserialize)]
390pub enum AgentEvent {
391    /// The user submitted a new task or message.
392    UserTaskSubmitted {
393        /// The user's input text.
394        text: String,
395        /// Optional multimodal content parts (images + text).
396        #[serde(default, skip_serializing_if = "Vec::is_empty")]
397        content_parts: Vec<crate::model::ContentPart>,
398        /// Unix timestamp (seconds since epoch) when the user submitted this
399        /// message. Used for wall-clock display in clients (e.g. TUI sticky bar).
400        /// Optional for backward-compatible session JSON (pre-timestamp sessions).
401        #[serde(default, skip_serializing_if = "Option::is_none")]
402        submitted_at: Option<u64>,
403    },
404    /// A complete model output with optional thinking/reasoning content.
405    ModelOutput {
406        /// The assistant's response text.
407        text: String,
408        /// Optional thinking or reasoning trace from the model.
409        #[serde(default)]
410        thinking: Option<String>,
411    },
412    /// A streaming text delta from the model.
413    ModelDelta {
414        /// Incremental text content.
415        text: String,
416    },
417    /// A streaming thinking/reasoning delta from the model.
418    ModelThinkingDelta {
419        /// Incremental thinking content.
420        text: String,
421    },
422    /// The assistant requested a tool invocation.
423    ToolRequested(ToolInvocation),
424    /// A tool invocation completed.
425    ToolCompleted(ToolResult),
426    /// Transient status for a nested subagent.
427    SubagentActivity {
428        /// Parent subagent tool invocation id.
429        invocation_id: String,
430        /// Human-readable description of the latest nested activity.
431        message: String,
432    },
433    /// Transient drill-down transcript item for a nested subagent.
434    SubagentTranscript {
435        /// Parent subagent tool invocation id.
436        invocation_id: String,
437        /// Transcript item to append for this UI session.
438        item: SubagentTranscriptItem,
439    },
440    /// A harness-level diagnostic trace.
441    HarnessTrace(Value),
442    /// The harness stopped a turn before another model iteration.
443    HarnessStopped {
444        /// Machine-readable stop reason.
445        reason: String,
446        /// Human-readable diagnostic.
447        message: String,
448        /// Tool involved in the stop, when applicable.
449        #[serde(default, skip_serializing_if = "Option::is_none")]
450        tool_name: Option<String>,
451    },
452    /// A file patch was proposed by the assistant.
453    PatchProposed(PatchProposal),
454    /// A tool invocation requires user approval.
455    ApprovalRequested(ApprovalRequest),
456    /// An approval request was resolved.
457    ApprovalResolved(ApprovalDecision),
458    /// A capability lifecycle event was recorded by the policy layer.
459    CapabilityRecorded(CapabilityLedgerEntry),
460    /// The assistant requested an interactive user choice.
461    QuestionRequested(QuestionRequest),
462    /// An interactive user choice was resolved.
463    QuestionResolved(QuestionResponse),
464    /// Plan tool create is waiting for user review (blocks the turn).
465    PlanReviewRequested(PlanReviewRequest),
466    /// User finished plan review (approve / changes / quit).
467    PlanReviewResolved(PlanReviewResponse),
468    /// Bash/`sudo` needs a password; TUI shows a masked modal. Password is
469    /// never included in this event — only a correlation id. The secret is
470    /// delivered solely through the sudo password resolver oneshot.
471    SudoPasswordRequested(SudoPasswordRequest),
472    /// The same tool was called consecutively with identical arguments.
473    /// The tool is NOT executed; this is a notification to the user.
474    RepeatedToolCallWarning {
475        /// Name of the repeated tool.
476        tool_name: String,
477        /// Warning message describing the repetition.
478        message: String,
479    },
480    /// Repetitive/degenerate model output was detected (character runs,
481    /// alternating patterns, or duplicate thinking blocks).
482    RepetitionDetected {
483        /// What kind of repetition was detected.
484        kind: RepetitionWarningKind,
485        /// Human-readable warning message.
486        message: String,
487    },
488    /// An error occurred.
489    Error {
490        /// Human-readable error message.
491        message: String,
492    },
493    /// Auto-dream memory consolidation has started.
494    AutoDreamStarted {
495        /// Hours since the last dream run.
496        hours_since_last: u64,
497        /// Number of sessions reviewed.
498        sessions_reviewed: usize,
499    },
500    /// Auto-dream memory consolidation has completed.
501    AutoDreamCompleted {
502        /// Memories marked stale.
503        marked_stale: usize,
504        /// Duplicates merged.
505        duplicates_merged: usize,
506        /// Active memories remaining.
507        active_count: usize,
508    },
509    /// Auto-dream memory consolidation has failed.
510    AutoDreamFailed {
511        /// Human-readable reason for the failure.
512        reason: String,
513    },
514    /// Token usage was reported by the model provider.
515    UsageReported {
516        /// Number of input/prompt tokens consumed.
517        input_tokens: u64,
518        /// Number of output/completion tokens produced.
519        output_tokens: u64,
520        /// Number of tokens written to the prompt cache (Anthropic).
521        cache_creation_tokens: u64,
522        /// Number of tokens read from the prompt cache (Anthropic).
523        cache_read_tokens: u64,
524    },
525    /// Short post-turn session recap ("Recap" line).
526    SessionRecap {
527        /// One- or two-sentence summary of the turn.
528        summary: String,
529        /// When true, the recap was generated but should not be shown (long-tail).
530        suppressed: bool,
531    },
532    /// The provider stream broke mid-generation and is being resumed via
533    /// prefill (assistant continuation). The UI can show a transient hint.
534    StreamResuming {
535        /// Characters of text accumulated before the break.
536        accumulated_chars: usize,
537        /// Retry attempt number (1-based).
538        attempt: u32,
539    },
540    /// The agent requested to set a goal via natural language.
541    SetGoalRequested {
542        /// The objective text.
543        objective: String,
544        /// Optional short UI label.
545        short_description: Option<String>,
546        /// Optional token budget.
547        token_budget: Option<i64>,
548    },
549    /// The session goal was updated (created, status change, budget exceeded).
550    GoalUpdated {
551        /// The session this goal belongs to.
552        session_id: String,
553        /// Unique identifier for the goal.
554        goal_id: String,
555        /// The objective text.
556        objective: String,
557        /// Optional short UI label.
558        short_description: Option<String>,
559        /// Current goal status.
560        status: GoalStatus,
561        /// Tokens consumed so far.
562        tokens_used: i64,
563        /// Optional token budget.
564        token_budget: Option<i64>,
565    },
566    /// Micro-compaction cleared stale tool results from history.
567    MicroCompactApplied {
568        /// Number of tool result messages cleared.
569        messages_cleared: usize,
570    },
571    /// Automatic conversation compaction started.
572    AutoCompactStarted,
573    /// Automatic conversation compaction completed.
574    AutoCompactCompleted {
575        /// Estimated tokens saved by compaction.
576        tokens_saved: u64,
577    },
578    /// Automatic conversation compaction failed.
579    AutoCompactFailed {
580        /// Human-readable failure reason.
581        reason: String,
582    },
583    /// The agent proposed a plan in Plan mode.
584    PlanProposed {
585        /// Title/summary of the plan.
586        title: String,
587        /// Ordered list of steps.
588        steps: Vec<String>,
589    },
590    /// The agent mode changed (e.g. Default → Plan or Plan → Default).
591    AgentModeChanged {
592        /// The new mode.
593        mode: crate::plan_mode::AgentMode,
594    },
595    /// Host should surface a user-visible notification.
596    ///
597    /// Desktop hosts call OS toasts; browser hosts map this to the Web
598    /// Notifications API. Payload mirrors [`crate::notify::NotifyRequest`].
599    NotificationRequested {
600        title: String,
601        body: String,
602        #[serde(default)]
603        urgency: crate::notify::NotificationUrgency,
604        #[serde(default, skip_serializing_if = "Option::is_none")]
605        category: Option<String>,
606    },
607    /// A newer NAVI release is available (from update check).
608    UpdateAvailable {
609        current_version: String,
610        latest_version: String,
611        latest_tag: String,
612        release_url: String,
613        #[serde(default, skip_serializing_if = "Option::is_none")]
614        body: Option<String>,
615        #[serde(default)]
616        prerelease: bool,
617    },
618}
619
620/// Kind of repetitive/degenerate output detected by the repetition detector.
621#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
622#[serde(tag = "type")]
623pub enum RepetitionWarningKind {
624    /// Same character repeated many times (e.g. "aaaaaa...").
625    CharRun {
626        /// The repeating character.
627        ch: char,
628        /// How many consecutive occurrences.
629        count: usize,
630    },
631    /// Two characters alternating many times (e.g. "-_-_-_").
632    AlternatingPattern {
633        /// The two-character pattern (e.g. "-_").
634        pattern: String,
635        /// How many cycles detected.
636        cycles: usize,
637    },
638}
639
640/// A pending approval request for a tool invocation that requires user consent.
641#[derive(Debug, Clone, Serialize, Deserialize)]
642pub struct ApprovalRequest {
643    /// Unique identifier for this approval request.
644    pub id: String,
645    /// Human-readable summary of what the tool will do.
646    pub summary: String,
647    /// The security risk category that triggered the approval requirement.
648    pub risk: ApprovalRisk,
649}
650
651/// A selectable option in a [`QuestionRequest`].
652#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
653pub struct QuestionOption {
654    /// Short option label shown in the selection UI and returned to the model.
655    pub label: String,
656    /// Optional explanatory text shown below the label.
657    #[serde(default, skip_serializing_if = "Option::is_none")]
658    pub description: Option<String>,
659}
660
661/// A pending interactive question requested by the assistant through the
662/// `question` tool.
663#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
664pub struct QuestionRequest {
665    /// Unique identifier matching the tool invocation id.
666    pub id: String,
667    /// Prompt shown to the user.
668    pub question: String,
669    /// Selectable options.
670    #[serde(default)]
671    pub options: Vec<QuestionOption>,
672    /// Whether more than one option may be selected.
673    #[serde(default)]
674    pub multiple: bool,
675    /// Whether the UI should allow a free-form custom answer.
676    #[serde(default)]
677    pub allow_custom: bool,
678}
679
680/// Resolution for an interactive [`QuestionRequest`].
681#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
682#[serde(tag = "kind", rename_all = "snake_case")]
683pub enum QuestionResponse {
684    /// The user selected one or more answers.
685    Answered {
686        /// Question/tool invocation id.
687        id: String,
688        /// Selected labels or the custom answer text.
689        answers: Vec<String>,
690    },
691    /// The user dismissed the question without answering.
692    Dismissed {
693        /// Question/tool invocation id.
694        id: String,
695    },
696}
697
698impl QuestionResponse {
699    /// Returns the request id this response resolves.
700    pub fn id(&self) -> &str {
701        match self {
702            Self::Answered { id, .. } | Self::Dismissed { id } => id,
703        }
704    }
705}
706
707/// Interactive plan review requested after `plan(action=create)`.
708/// The turn **blocks** until the user resolves it.
709#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
710pub struct PlanReviewRequest {
711    /// Tool invocation id (used as correlation key for the oneshot).
712    pub id: String,
713    /// Persisted plan id in SQLite.
714    pub plan_id: String,
715    pub title: String,
716    pub description: String,
717    pub steps: Vec<String>,
718}
719
720/// User decision from the plan review modal.
721#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
722#[serde(rename_all = "snake_case")]
723pub enum PlanReviewDecision {
724    Approve,
725    RequestChanges,
726    Quit,
727}
728
729/// Resolution for a blocked plan review.
730#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
731pub struct PlanReviewResponse {
732    /// Matching tool invocation id.
733    pub id: String,
734    pub plan_id: String,
735    pub decision: PlanReviewDecision,
736    /// Line-oriented comments from the modal.
737    #[serde(default)]
738    pub comments: Vec<crate::plan_store::PlanLineComment>,
739    /// Freeform notes from the prompt field.
740    #[serde(default)]
741    pub freeform: String,
742}
743
744impl PlanReviewResponse {
745    pub fn id(&self) -> &str {
746        &self.id
747    }
748}
749
750/// Request for a sudo password from the interactive TUI.
751///
752/// **Security:** this event must never carry the password itself — only a
753/// correlation id and UI context (command summary). The secret is delivered
754/// solely through [`crate::runtime::SudoPasswordResolver`].
755#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
756pub struct SudoPasswordRequest {
757    /// Correlation id for the oneshot resolver.
758    pub id: String,
759    /// Short, non-secret description (e.g. truncated command).
760    pub command_summary: String,
761}
762
763/// Resolution of a sudo password prompt.
764///
765/// Prefer not to serialize responses that still hold a password into logs.
766#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
767#[serde(tag = "kind", rename_all = "snake_case")]
768pub enum SudoPasswordResponse {
769    /// User entered a password. Cleared from memory after bash consumes it.
770    Submitted {
771        id: String,
772        /// Secret — never write to chat history or tool observations.
773        #[serde(skip_serializing)]
774        password: String,
775    },
776    /// User cancelled the modal.
777    Cancelled { id: String },
778}
779
780impl SudoPasswordResponse {
781    pub fn id(&self) -> &str {
782        match self {
783            Self::Submitted { id, .. } | Self::Cancelled { id } => id,
784        }
785    }
786}
787
788/// The security risk category associated with an approval request.
789#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
790pub enum ApprovalRisk {
791    /// Any tool execution in restricted mode.
792    Tool,
793    /// A file write operation.
794    Write,
795    /// A shell command execution.
796    Command,
797    /// A guarded command that requires explicit approval outside YOLO mode.
798    Guarded,
799    /// Loading or executing an external plugin.
800    ExternalPlugin,
801}
802
803/// The outcome of an approval request.
804#[derive(Debug, Clone, Serialize, Deserialize)]
805pub enum ApprovalDecision {
806    /// The user approved the action.
807    Approved {
808        /// Identifier matching the [`ApprovalRequest::id`].
809        id: String,
810    },
811    /// The user denied the action.
812    Denied {
813        /// Identifier matching the [`ApprovalRequest::id`].
814        id: String,
815    },
816}