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}