Skip to main content

lash_plugin_plan_mode/
plan_mode.rs

1use std::collections::BTreeSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex};
5
6use serde_json::json;
7
8use lash_core::plugin::{
9    PluginAction, PluginActionFailure, PluginActionKind, PluginDirective, PluginError,
10    PluginFactory, PluginRegistrar, PluginSessionContext, PluginSnapshotMeta, SessionParam,
11    SessionPlugin, SnapshotReader, SnapshotWriter, ToolCatalogContribution, ToolCatalogOverride,
12};
13use lash_core::{
14    JsonSchema, PluginMessage, ToolCall, ToolContext, ToolControl, ToolDefinition, ToolResult,
15    ToolScheduling,
16};
17use lash_tool_support::{StaticToolExecute, StaticToolProvider};
18use lash_tools::apply_patch::{PatchAction, inspect_patch_ops};
19
20mod prompt;
21mod state;
22
23pub use prompt::{
24    PlanModePrompt, PlanModePromptRequest, PlanModePromptResponse, PlanModePromptReview,
25};
26use prompt::{
27    plan_exit_confirmation_display, plan_exit_fresh_context_input, plan_exit_next_turn_input,
28    plan_mode_guidance_message, plan_mode_tool_note,
29};
30#[cfg(test)]
31use state::PLAN_TEMPLATE;
32use state::{
33    PlanModeSnapshot, PlanModeState, PlanReport, effective_run_session_id, plan_display_path,
34    read_plan_report, resolve_plan_path, seed_plan_template,
35};
36
37const PLAN_MODE_STATE_EVENT: &str = "plan_mode.state";
38
39fn default_allowed_tools() -> BTreeSet<String> {
40    [
41        "ask",
42        "fetch_url",
43        "glob",
44        "grep",
45        "ls",
46        "read_file",
47        "search_tools",
48        "search_web",
49        "apply_patch",
50        "plan_exit",
51    ]
52    .into_iter()
53    .map(str::to_string)
54    .collect()
55}
56
57fn fresh_context_frame_id() -> String {
58    format!("plan-frame-{}", uuid::Uuid::new_v4().simple())
59}
60
61fn plan_protocol_state_event(
62    session_id: &str,
63    enabled: bool,
64    report: Option<&PlanReport>,
65) -> Result<lash_core::PluginRuntimeEvent, PluginError> {
66    Ok(lash_core::PluginRuntimeEvent::Custom {
67        name: PLAN_MODE_STATE_EVENT.to_string(),
68        payload: serde_json::to_value(plan_mode_payload(session_id, enabled, report)).map_err(
69            |err| PluginError::Session(format!("failed to encode plan mode state: {err}")),
70        )?,
71    })
72}
73
74#[derive(Clone, Debug)]
75pub struct PlanModePluginConfig {
76    pub allowed_tools: BTreeSet<String>,
77}
78
79impl Default for PlanModePluginConfig {
80    fn default() -> Self {
81        Self {
82            allowed_tools: default_allowed_tools(),
83        }
84    }
85}
86
87impl PlanModePluginConfig {
88    pub fn with_allowed_tools<I, S>(mut self, allowed_tools: I) -> Self
89    where
90        I: IntoIterator<Item = S>,
91        S: Into<String>,
92    {
93        self.allowed_tools = allowed_tools.into_iter().map(Into::into).collect();
94        self.allowed_tools.insert("plan_exit".to_string());
95        self
96    }
97}
98
99#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize, JsonSchema)]
100pub struct PlanModeExternalArgs {}
101
102#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize, JsonSchema)]
103pub struct PlanModeExternalStatus {
104    pub session_id: String,
105    pub enabled: bool,
106    pub plan_path: Option<String>,
107}
108
109pub struct PlanModeEnableOp;
110pub struct PlanModeDisableOp;
111pub struct PlanModeToggleOp;
112
113impl PluginAction for PlanModeEnableOp {
114    const NAME: &'static str = "plan_mode.enable";
115    const DESCRIPTION: &'static str = "Enable plan mode for this session.";
116    const KIND: PluginActionKind = PluginActionKind::Command;
117    const SESSION_PARAM: SessionParam = SessionParam::Required;
118    type Args = PlanModeExternalArgs;
119    type Output = PlanModeExternalStatus;
120}
121
122impl PluginAction for PlanModeDisableOp {
123    const NAME: &'static str = "plan_mode.disable";
124    const DESCRIPTION: &'static str = "Disable plan mode for this session.";
125    const KIND: PluginActionKind = PluginActionKind::Command;
126    const SESSION_PARAM: SessionParam = SessionParam::Required;
127    type Args = PlanModeExternalArgs;
128    type Output = PlanModeExternalStatus;
129}
130
131impl PluginAction for PlanModeToggleOp {
132    const NAME: &'static str = "plan_mode.toggle";
133    const DESCRIPTION: &'static str = "Toggle plan mode for this session.";
134    const KIND: PluginActionKind = PluginActionKind::Command;
135    const SESSION_PARAM: SessionParam = SessionParam::Required;
136    type Args = PlanModeExternalArgs;
137    type Output = PlanModeExternalStatus;
138}
139
140async fn ensure_plan_path<H>(
141    state: &Arc<Mutex<PlanModeState>>,
142    session_id: &str,
143    host: &Arc<H>,
144) -> Result<PathBuf, PluginError>
145where
146    H: lash_core::plugin::runtime_host::SessionStateService + ?Sized,
147{
148    if let Some(path) = state
149        .lock()
150        .map_err(|_| PluginError::Session("plan mode state poisoned".to_string()))?
151        .plan_path()
152    {
153        return Ok(path);
154    }
155
156    let snapshot = host.snapshot_session(session_id).await?;
157    let run_session_id =
158        effective_run_session_id(&snapshot.session_id, &snapshot.policy).to_string();
159    let path = resolve_plan_path(&run_session_id).map_err(PluginError::Session)?;
160    state
161        .lock()
162        .map_err(|_| PluginError::Session("plan mode state poisoned".to_string()))?
163        .set_plan_path(path.clone());
164    Ok(path)
165}
166
167fn ensure_plan_path_from_snapshot(
168    state: &Arc<Mutex<PlanModeState>>,
169    snapshot: &lash_core::SessionSnapshot,
170) -> Result<PathBuf, PluginError> {
171    if let Some(path) = state
172        .lock()
173        .map_err(|_| PluginError::Session("plan mode state poisoned".to_string()))?
174        .plan_path()
175    {
176        return Ok(path);
177    }
178    let run_session_id =
179        effective_run_session_id(&snapshot.session_id, &snapshot.policy).to_string();
180    let path = resolve_plan_path(&run_session_id).map_err(PluginError::Session)?;
181    state
182        .lock()
183        .map_err(|_| PluginError::Session("plan mode state poisoned".to_string()))?
184        .set_plan_path(path.clone());
185    Ok(path)
186}
187
188async fn ensure_plan_report_for_tool_context(
189    state: &Arc<Mutex<PlanModeState>>,
190    context: &ToolContext<'_>,
191    seed_if_missing: bool,
192) -> Result<PlanReport, PluginError> {
193    let snapshot = context.sessions().snapshot_current().await?;
194    let path = ensure_plan_path_from_snapshot(state, &snapshot)?;
195    if seed_if_missing {
196        seed_plan_template(&path).map_err(PluginError::Session)?;
197    }
198    read_plan_report(&path).map_err(PluginError::Session)
199}
200
201async fn ensure_plan_report<H>(
202    state: &Arc<Mutex<PlanModeState>>,
203    session_id: &str,
204    host: &Arc<H>,
205    seed_if_missing: bool,
206) -> Result<PlanReport, PluginError>
207where
208    H: lash_core::plugin::runtime_host::SessionStateService + ?Sized,
209{
210    let path = ensure_plan_path(state, session_id, host).await?;
211    if seed_if_missing {
212        seed_plan_template(&path).map_err(PluginError::Session)?;
213    }
214    read_plan_report(&path).map_err(PluginError::Session)
215}
216
217fn tool_state_unavailable(err: &PluginError) -> bool {
218    matches!(err, PluginError::Session(message) if message.contains("tool state"))
219}
220
221async fn sync_plan_exit_tool_state<H>(
222    host: &Arc<H>,
223    session_id: &str,
224    enabled: bool,
225) -> Result<(), PluginError>
226where
227    H: lash_core::plugin::runtime_host::SessionStateService + ?Sized,
228{
229    let availability = if enabled {
230        Some(lash_core::ToolAvailability::Showcased)
231    } else {
232        Some(lash_core::ToolAvailability::Off)
233    };
234    match host
235        .set_tool_availability(session_id, "plan_exit", availability)
236        .await
237    {
238        Ok(_) => Ok(()),
239        Err(err) if tool_state_unavailable(&err) => Ok(()),
240        Err(err) => Err(err),
241    }
242}
243
244async fn set_plan_mode_enabled_state<H>(
245    state: &Arc<Mutex<PlanModeState>>,
246    session_id: &str,
247    host: &Arc<H>,
248    enabled: bool,
249) -> Result<bool, PluginError>
250where
251    H: lash_core::plugin::runtime_host::SessionStateService + ?Sized,
252{
253    let previous = {
254        let mut guard = state
255            .lock()
256            .map_err(|_| PluginError::Session("plan mode state poisoned".to_string()))?;
257        let previous = guard.enabled;
258        if previous != enabled {
259            guard.set_enabled(enabled);
260        }
261        previous
262    };
263    if let Err(err) = sync_plan_exit_tool_state(host, session_id, enabled).await {
264        if previous != enabled {
265            let mut guard = state
266                .lock()
267                .map_err(|_| PluginError::Session("plan mode state poisoned".to_string()))?;
268            guard.set_enabled(previous);
269        }
270        return Err(err);
271    }
272    Ok(enabled)
273}
274
275async fn set_plan_mode_enabled_state_for_tool_context(
276    state: &Arc<Mutex<PlanModeState>>,
277    context: &ToolContext<'_>,
278    enabled: bool,
279) -> Result<bool, PluginError> {
280    let previous = {
281        let mut guard = state
282            .lock()
283            .map_err(|_| PluginError::Session("plan mode state poisoned".to_string()))?;
284        let previous = guard.enabled;
285        if previous != enabled {
286            guard.set_enabled(enabled);
287        }
288        previous
289    };
290    let availability = if enabled {
291        Some(lash_core::ToolAvailability::Showcased)
292    } else {
293        Some(lash_core::ToolAvailability::Off)
294    };
295    let names = vec!["plan_exit".to_string()];
296    if let Err(err) = context
297        .sessions()
298        .set_tools_availability(&names, availability)
299        .await
300        && !tool_state_unavailable(&err)
301    {
302        if previous != enabled {
303            let mut guard = state
304                .lock()
305                .map_err(|_| PluginError::Session("plan mode state poisoned".to_string()))?;
306            guard.set_enabled(previous);
307        }
308        return Err(err);
309    }
310    Ok(enabled)
311}
312
313fn plan_mode_payload(
314    session_id: &str,
315    enabled: bool,
316    report: Option<&PlanReport>,
317) -> PlanModeExternalStatus {
318    PlanModeExternalStatus {
319        session_id: session_id.to_string(),
320        enabled,
321        plan_path: report.map(|value| value.display_path.clone()),
322    }
323}
324
325fn patch_allowed_for_plan_file(args: &serde_json::Value, plan_path: &Path) -> Result<(), String> {
326    let input = args
327        .get("input")
328        .and_then(|value| value.as_str())
329        .ok_or_else(|| "plan mode requires `apply_patch.input`".to_string())?;
330    let workdir = args
331        .get("workdir")
332        .and_then(|value| value.as_str())
333        .filter(|value| !value.is_empty());
334    let ops = inspect_patch_ops(input, workdir)?;
335    if ops.is_empty() {
336        return Err("plan mode requires a non-empty patch".to_string());
337    }
338    for op in ops {
339        match op.action {
340            PatchAction::Add | PatchAction::Update
341                if op.path == plan_path && op.move_path.is_none() => {}
342            PatchAction::Add | PatchAction::Update => {
343                return Err(format!(
344                    "plan mode only allows `apply_patch` to edit `{}`",
345                    plan_display_path(plan_path)
346                ));
347            }
348            PatchAction::Delete => {
349                return Err(format!(
350                    "plan mode does not allow deleting `{}`",
351                    plan_display_path(plan_path)
352                ));
353            }
354        }
355    }
356    Ok(())
357}
358
359#[derive(Clone)]
360struct PlanModeTools {
361    state: Arc<Mutex<PlanModeState>>,
362    prompt: Option<Arc<dyn PlanModePrompt>>,
363}
364
365impl PlanModeTools {
366    async fn execute_plan_exit(&self, context: &ToolContext<'_>) -> ToolResult {
367        let enabled = match self.state.lock() {
368            Ok(guard) => guard.enabled,
369            Err(_) => return ToolResult::err(json!("plan mode state poisoned")),
370        };
371        if !enabled {
372            return ToolResult::err(json!("plan mode is not active"));
373        }
374
375        let report = match ensure_plan_report_for_tool_context(&self.state, context, true).await {
376            Ok(report) => report,
377            Err(err) => return ToolResult::err(json!(err.to_string())),
378        };
379
380        let Some(prompt) = &self.prompt else {
381            return ToolResult::err(json!(
382                "plan approval prompts are unavailable in this session"
383            ));
384        };
385        let answer = match prompt
386            .prompt_user(
387                PlanModePromptRequest::single(
388                    format!("Review the plan in `{}`. What next?", report.display_path),
389                    vec![
390                        "Start implementing now".to_string(),
391                        "Keep planning".to_string(),
392                        "Start in fresh context".to_string(),
393                    ],
394                )
395                .with_review("PLAN", report.approval_content())
396                .with_optional_note(),
397            )
398            .await
399        {
400            Ok(answer) => answer,
401            Err(err) => return ToolResult::err(json!(err.to_string())),
402        };
403
404        let selection = match &answer {
405            PlanModePromptResponse::Single { selection, .. } => selection.as_str(),
406        };
407        if selection == "Keep planning" {
408            return ToolResult::ok(json!({
409                "approved": false,
410                "plan_path": report.display_path,
411                "answer": answer,
412            }));
413        }
414
415        let note = match &answer {
416            PlanModePromptResponse::Single { note, .. } => note.clone(),
417        };
418
419        if let Err(err) =
420            set_plan_mode_enabled_state_for_tool_context(&self.state, context, false).await
421        {
422            return ToolResult::err(json!(err.to_string()));
423        }
424
425        if selection == "Start in fresh context" {
426            return ToolResult::ok(json!({
427                "approved": true,
428                "plan_path": report.display_path,
429                "execution_mode": "fresh_context",
430            }));
431        }
432
433        ToolResult::ok(json!({
434            "approved": true,
435            "answer": answer,
436            "confirmation_display": plan_exit_confirmation_display(selection, note.as_deref()),
437            "plan_path": report.display_path,
438            "execution_mode": "current_session",
439            "next_turn_input": plan_exit_next_turn_input(&report.display_path, note.as_deref()),
440        }))
441    }
442}
443
444fn plan_mode_provider(
445    state: Arc<Mutex<PlanModeState>>,
446    prompt: Option<Arc<dyn PlanModePrompt>>,
447) -> StaticToolProvider<PlanModeTools> {
448    StaticToolProvider::new(
449        vec![plan_exit_tool_definition()],
450        PlanModeTools { state, prompt },
451    )
452}
453
454#[async_trait::async_trait]
455impl StaticToolExecute for PlanModeTools {
456    async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
457        match call.name {
458            "plan_exit" => self.execute_plan_exit(call.context).await,
459            other => ToolResult::err_fmt(format_args!("Unknown tool: {other}")),
460        }
461    }
462}
463
464fn plan_exit_tool_definition() -> ToolDefinition {
465    ToolDefinition::raw(
466        "tool:plan_exit",
467        "plan_exit",
468        "Ask whether to exit plan mode.",
469        plan_exit_input_schema(),
470        plan_exit_output_schema(),
471    )
472    .with_examples(vec!["plan_exit()".into()])
473    .with_availability(lash_core::ToolAvailabilityConfig::off())
474    .with_scheduling(ToolScheduling::Parallel)
475}
476
477fn plan_exit_input_schema() -> serde_json::Value {
478    serde_json::json!({
479        "type": "object",
480        "properties": {},
481        "additionalProperties": false
482    })
483}
484
485fn plan_exit_output_schema() -> serde_json::Value {
486    serde_json::json!({
487        "type": "object",
488        "properties": {
489            "approved": { "type": "boolean" },
490            "plan_path": { "type": "string" },
491            "answer": {
492                "type": "object",
493                "properties": {
494                    "kind": { "type": "string", "enum": ["single"] },
495                    "selection": { "type": "string" },
496                    "note": { "type": "string" }
497                },
498                "required": ["kind", "selection"],
499                "additionalProperties": false
500            },
501            "execution_mode": {
502                "type": "string",
503                "enum": ["current_session", "fresh_context"]
504            },
505            "confirmation_display": { "type": "string" },
506            "next_turn_input": { "type": "string" }
507        },
508        "required": ["approved", "plan_path"],
509        "additionalProperties": false
510    })
511}
512
513pub struct PlanModePluginFactory {
514    config: PlanModePluginConfig,
515    prompt: Option<Arc<dyn PlanModePrompt>>,
516}
517
518impl Default for PlanModePluginFactory {
519    fn default() -> Self {
520        Self::new(PlanModePluginConfig::default())
521    }
522}
523
524impl PlanModePluginFactory {
525    pub fn new(config: PlanModePluginConfig) -> Self {
526        Self {
527            config,
528            prompt: None,
529        }
530    }
531
532    pub fn with_prompt(mut self, prompt: Arc<dyn PlanModePrompt>) -> Self {
533        self.prompt = Some(prompt);
534        self
535    }
536}
537
538impl PluginFactory for PlanModePluginFactory {
539    fn id(&self) -> &'static str {
540        "plan_mode"
541    }
542
543    fn build(&self, _ctx: &PluginSessionContext) -> Result<Arc<dyn SessionPlugin>, PluginError> {
544        Ok(Arc::new(PlanModePlugin {
545            state: Arc::new(Mutex::new(PlanModeState::default())),
546            config: self.config.clone(),
547            prompt: self.prompt.clone(),
548        }))
549    }
550}
551
552struct PlanModePlugin {
553    state: Arc<Mutex<PlanModeState>>,
554    config: PlanModePluginConfig,
555    prompt: Option<Arc<dyn PlanModePrompt>>,
556}
557
558impl SessionPlugin for PlanModePlugin {
559    fn id(&self) -> &'static str {
560        "plan_mode"
561    }
562
563    fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
564        reg.tools().provider(Arc::new(plan_mode_provider(
565            Arc::clone(&self.state),
566            self.prompt.clone(),
567        )))?;
568
569        let before_turn_state = Arc::clone(&self.state);
570        reg.turn().before(Arc::new(move |ctx| {
571            let state = Arc::clone(&before_turn_state);
572            Box::pin(async move {
573                let plan_path = {
574                    let mut state = state.lock().map_err(|_| {
575                        PluginError::Session("plan mode state poisoned".to_string())
576                    })?;
577                    let should_inject = state.prepare_turn();
578                    if !should_inject {
579                        return Ok(Vec::new());
580                    }
581                    state.ensure_plan_path_from_state(&ctx.state.to_snapshot())?
582                };
583                seed_plan_template(&plan_path).map_err(PluginError::Session)?;
584                let report = read_plan_report(&plan_path).map_err(PluginError::Session)?;
585                Ok(vec![
586                    PluginDirective::emit_runtime_events(vec![plan_protocol_state_event(
587                        &ctx.session_id,
588                        true,
589                        Some(&report),
590                    )?]),
591                    PluginDirective::EnqueueMessages {
592                        messages: vec![plan_mode_guidance_message(&plan_path)],
593                    },
594                ])
595            })
596        }));
597
598        let checkpoint_state = Arc::clone(&self.state);
599        reg.turn().checkpoint(Arc::new(move |ctx| {
600            let state = Arc::clone(&checkpoint_state);
601            Box::pin(async move {
602                let plan_path = {
603                    let mut state = state.lock().map_err(|_| {
604                        PluginError::Session("plan mode state poisoned".to_string())
605                    })?;
606                    let should_inject = state.checkpoint_injection_needed();
607                    if !should_inject {
608                        return Ok(Vec::new());
609                    }
610                    state.ensure_plan_path_from_state(&ctx.state.to_snapshot())?
611                };
612                seed_plan_template(&plan_path).map_err(PluginError::Session)?;
613                let report = read_plan_report(&plan_path).map_err(PluginError::Session)?;
614                Ok(vec![
615                    PluginDirective::emit_runtime_events(vec![plan_protocol_state_event(
616                        &ctx.session_id,
617                        true,
618                        Some(&report),
619                    )?]),
620                    PluginDirective::EnqueueMessages {
621                        messages: vec![plan_mode_guidance_message(&plan_path)],
622                    },
623                ])
624            })
625        }));
626
627        let after_turn_state = Arc::clone(&self.state);
628        reg.turn().after(Arc::new(move |_ctx| {
629            let state = Arc::clone(&after_turn_state);
630            Box::pin(async move {
631                state
632                    .lock()
633                    .map_err(|_| PluginError::Session("plan mode state poisoned".to_string()))?
634                    .finish_turn();
635                Ok(Vec::new())
636            })
637        }));
638
639        let before_tool_state = Arc::clone(&self.state);
640        let before_tool_config = self.config.clone();
641        reg.tool_calls().before(Arc::new(move |ctx| {
642            let state = Arc::clone(&before_tool_state);
643            let config = before_tool_config.clone();
644            Box::pin(async move {
645                let enabled = state
646                    .lock()
647                    .map_err(|_| PluginError::Session("plan mode state poisoned".to_string()))?
648                    .enabled;
649                if !enabled {
650                    return Ok(Vec::new());
651                }
652
653                if ctx.tool_name != "plan_exit" && !config.allowed_tools.contains(&ctx.tool_name) {
654                    return Ok(vec![PluginDirective::AbortTurn {
655                        code: "plan_mode_tool_blocked".to_string(),
656                        message: format!(
657                            "Plan mode blocks `{}`. Use planning tools or `plan_exit()`.",
658                            ctx.tool_name
659                        ),
660                    }]);
661                }
662
663                if ctx.tool_name == "apply_patch" {
664                    let snapshot = ctx.session_snapshot().await?;
665                    let plan_path = ensure_plan_path_from_snapshot(&state, &snapshot)?;
666                    if let Err(message) = patch_allowed_for_plan_file(&ctx.args, &plan_path) {
667                        return Ok(vec![PluginDirective::AbortTurn {
668                            code: "plan_mode_tool_blocked".to_string(),
669                            message,
670                        }]);
671                    }
672                }
673
674                Ok(Vec::new())
675            })
676        }));
677
678        let after_tool_state = Arc::clone(&self.state);
679        reg.tool_calls().after(Arc::new(move |ctx| {
680            let state = Arc::clone(&after_tool_state);
681            Box::pin(async move {
682                let result_value = ctx.result.value_for_projection();
683                let approved = ctx.tool_name == "plan_exit"
684                    && ctx.result.is_success()
685                    && result_value
686                        .get("approved")
687                        .and_then(|value| value.as_bool())
688                        .unwrap_or(false);
689                if approved {
690                    let mut directives = vec![PluginDirective::emit_runtime_events(vec![
691                        plan_protocol_state_event(&ctx.session_id, false, None)?,
692                    ])];
693                    if result_value
694                        .get("execution_mode")
695                        .and_then(|value| value.as_str())
696                        == Some("fresh_context")
697                    {
698                        let plan_path = result_value
699                            .get("plan_path")
700                            .and_then(|value| value.as_str())
701                            .unwrap_or_default()
702                            .to_string();
703                        let frame_id = fresh_context_frame_id();
704                        let task = plan_exit_fresh_context_input(&plan_path);
705                        directives.push(PluginDirective::short_circuit(
706                            ToolResult::ok(json!({
707                                "approved": true,
708                                "plan_path": plan_path,
709                                "execution_mode": "fresh_context",
710                                "frame_id": frame_id.clone(),
711                            }))
712                            .with_control(
713                                ToolControl::SwitchAgentFrame {
714                                    frame_id,
715                                    initial_nodes: Vec::new(),
716                                    task: Some(task),
717                                },
718                            ),
719                        ));
720                    }
721                    return Ok(directives);
722                }
723
724                let enabled = state
725                    .lock()
726                    .map_err(|_| PluginError::Session("plan mode state poisoned".to_string()))?
727                    .enabled;
728                if !enabled || ctx.tool_name != "apply_patch" || !ctx.result.is_success() {
729                    return Ok(Vec::new());
730                }
731
732                let snapshot = ctx.session_snapshot().await?;
733                let path = ensure_plan_path_from_snapshot(&state, &snapshot)?;
734                let report = read_plan_report(&path).map_err(PluginError::Session)?;
735                Ok(vec![PluginDirective::emit_runtime_events(vec![
736                    plan_protocol_state_event(&ctx.session_id, true, Some(&report))?,
737                ])])
738            })
739        }));
740
741        let tool_catalog_state = Arc::clone(&self.state);
742        let tool_catalog_config = self.config.clone();
743        reg.tool_catalog().contribute(Arc::new(move |ctx| {
744            let state = tool_catalog_state
745                .lock()
746                .map_err(|_| PluginError::Session("plan mode state poisoned".to_string()))?;
747            if !state.enabled {
748                return Ok(ToolCatalogContribution::default());
749            }
750
751            let mut overrides = ctx
752                .tools
753                .iter()
754                .filter(|tool| !tool_catalog_config.allowed_tools.contains(&tool.name))
755                .map(|tool| ToolCatalogOverride {
756                    tool_name: tool.name.clone(),
757                    availability: Some(lash_core::ToolAvailability::Off),
758                })
759                .collect::<Vec<_>>();
760            overrides.push(ToolCatalogOverride {
761                tool_name: "plan_exit".to_string(),
762                availability: Some(lash_core::ToolAvailability::Showcased),
763            });
764            if tool_catalog_config.allowed_tools.contains("apply_patch") {
765                overrides.push(ToolCatalogOverride {
766                    tool_name: "apply_patch".to_string(),
767                    availability: Some(lash_core::ToolAvailability::Showcased),
768                });
769            }
770
771            Ok(ToolCatalogContribution {
772                overrides,
773                tool_list_notes: vec![plan_mode_tool_note(state.plan_path().as_deref())],
774            })
775        }));
776
777        register_plan_mode_op::<PlanModeEnableOp>(reg, Arc::clone(&self.state))?;
778        register_plan_mode_op::<PlanModeDisableOp>(reg, Arc::clone(&self.state))?;
779        register_plan_mode_op::<PlanModeToggleOp>(reg, Arc::clone(&self.state))?;
780
781        Ok(())
782    }
783
784    fn snapshot(
785        &self,
786        _writer: &mut dyn SnapshotWriter,
787    ) -> Result<PluginSnapshotMeta, PluginError> {
788        let snapshot = self
789            .state
790            .lock()
791            .map_err(|_| PluginError::Snapshot("plan mode state poisoned".to_string()))?
792            .snapshot();
793        Ok(PluginSnapshotMeta {
794            plugin_id: self.id().to_string(),
795            plugin_version: self.version().to_string(),
796            revision: snapshot.generation,
797            state: Some(json!({
798                "enabled": snapshot.enabled,
799                "generation": snapshot.generation,
800                "plan_path": snapshot.plan_path,
801            })),
802        })
803    }
804
805    fn restore(
806        &self,
807        meta: &PluginSnapshotMeta,
808        _reader: &dyn SnapshotReader,
809    ) -> Result<(), PluginError> {
810        let snapshot = meta
811            .state
812            .clone()
813            .map(serde_json::from_value::<PlanModeSnapshot>)
814            .transpose()
815            .map_err(|err| PluginError::Snapshot(err.to_string()))?
816            .unwrap_or_default();
817        self.state
818            .lock()
819            .map_err(|_| PluginError::Snapshot("plan mode state poisoned".to_string()))?
820            .restore_snapshot(snapshot);
821        Ok(())
822    }
823
824    fn snapshot_revision(&self) -> u64 {
825        self.state
826            .lock()
827            .map(|state| state.generation)
828            .unwrap_or_default()
829    }
830}
831
832fn register_plan_mode_op<Op>(
833    reg: &mut PluginRegistrar,
834    state: Arc<Mutex<PlanModeState>>,
835) -> Result<(), PluginError>
836where
837    Op: PluginAction<Args = PlanModeExternalArgs, Output = PlanModeExternalStatus>,
838{
839    reg.actions().typed::<Op, _, _>(move |ctx, _args| {
840        let state = Arc::clone(&state);
841        async move {
842            let Some(session_id) = ctx.session_id else {
843                return Err(PluginActionFailure::new(format!(
844                    "{} requires session_id",
845                    Op::NAME
846                )));
847            };
848            let target_enabled = match state.lock() {
849                Ok(guard) => match Op::NAME {
850                    "plan_mode.enable" => true,
851                    "plan_mode.disable" => false,
852                    "plan_mode.toggle" => !guard.enabled,
853                    _ => unreachable!(),
854                },
855                Err(_) => return Err(PluginActionFailure::new("plan mode state poisoned")),
856            };
857            let enabled =
858                set_plan_mode_enabled_state(&state, &session_id, &ctx.sessions, target_enabled)
859                    .await?;
860            let report = ensure_plan_report(&state, &session_id, &ctx.sessions, enabled).await?;
861            Ok(plan_mode_payload(&session_id, enabled, Some(&report)))
862        }
863    })
864}
865
866#[cfg(test)]
867mod tests {
868    use super::{
869        PLAN_TEMPLATE, plan_exit_fresh_context_input, plan_exit_next_turn_input,
870        plan_exit_tool_definition, read_plan_report,
871    };
872
873    #[test]
874    fn plan_exit_contract_documents_decision_result() {
875        let definition = plan_exit_tool_definition();
876
877        assert_eq!(
878            definition.contract.input_schema["additionalProperties"],
879            serde_json::json!(false)
880        );
881        assert_eq!(
882            definition.contract.output_schema["required"],
883            serde_json::json!(["approved", "plan_path"])
884        );
885        assert!(
886            definition
887                .contract
888                .output_schema
889                .to_string()
890                .contains("execution_mode")
891        );
892    }
893
894    #[test]
895    fn plan_exit_next_turn_input_appends_user_note() {
896        assert_eq!(
897            plan_exit_next_turn_input(
898                ".lash/plans/run-session.md",
899                Some("start with the safe slice"),
900            ),
901            "The user approved the plan. Execute the plan in `.lash/plans/run-session.md` now — start immediately, do not ask for confirmation.\n\nUser note: start with the safe slice"
902        );
903        assert_eq!(
904            plan_exit_next_turn_input(".lash/plans/run-session.md", Some("   ")),
905            "The user approved the plan. Execute the plan in `.lash/plans/run-session.md` now — start immediately, do not ask for confirmation."
906        );
907    }
908
909    #[test]
910    fn plan_exit_fresh_context_input_is_short_and_direct() {
911        assert_eq!(
912            plan_exit_fresh_context_input(".lash/plans/run-session.md"),
913            "Do a full, faithful implementation of the plan found at: .lash/plans/run-session.md"
914        );
915    }
916
917    #[test]
918    fn seeded_template_is_readable_without_validation() {
919        let dir = tempfile::tempdir().expect("tempdir");
920        let path = dir.path().join("plan.md");
921        std::fs::write(&path, PLAN_TEMPLATE).expect("write template");
922        let report = read_plan_report(&path).expect("report");
923        assert_eq!(report.content.as_deref(), Some(PLAN_TEMPLATE));
924    }
925}