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}