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