瀏覽代碼

Prevent long sessions from stalling and expose the requested internal command surface

The runtime now auto-compacts completed conversations once cumulative input usage
crosses a configurable threshold, preserving recent context while surfacing an
explicit user notice. The CLI also publishes the requested ant-only slash
commands through the shared commands crate and main dispatch, using meaningful
local implementations for commit/PR/issue/teleport/debug workflows.

Constraint: Reuse the existing Rust compaction pipeline instead of introducing a new summarization stack
Constraint: No new dependencies or broad command-framework rewrite
Rejected: Implement API-driven compaction inside ConversationRuntime now | too much new plumbing for this delivery
Rejected: Expose new commands as parse-only stubs | would not satisfy the requested command availability
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: If runtime later gains true API-backed compaction, preserve the TurnSummary auto-compaction metadata shape so CLI call sites stay stable
Tested: cargo test; cargo build --release; cargo fmt --all; git diff --check; LSP diagnostics directory check
Not-tested: Live Anthropic-backed specialist command flows; gh-authenticated PR/issue creation in a real repo
Yeachan-Heo 2 月之前
父節點
當前提交
992681c4fd

+ 151 - 1
rust/crates/commands/src/lib.rs

@@ -117,6 +117,48 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
         argument_hint: None,
         resume_supported: true,
     },
+    SlashCommandSpec {
+        name: "bughunter",
+        summary: "Inspect the codebase for likely bugs",
+        argument_hint: Some("[scope]"),
+        resume_supported: false,
+    },
+    SlashCommandSpec {
+        name: "commit",
+        summary: "Generate a commit message and create a git commit",
+        argument_hint: None,
+        resume_supported: false,
+    },
+    SlashCommandSpec {
+        name: "pr",
+        summary: "Draft or create a pull request from the conversation",
+        argument_hint: Some("[context]"),
+        resume_supported: false,
+    },
+    SlashCommandSpec {
+        name: "issue",
+        summary: "Draft or create a GitHub issue from the conversation",
+        argument_hint: Some("[context]"),
+        resume_supported: false,
+    },
+    SlashCommandSpec {
+        name: "ultraplan",
+        summary: "Run a deep planning prompt with multi-step reasoning",
+        argument_hint: Some("[task]"),
+        resume_supported: false,
+    },
+    SlashCommandSpec {
+        name: "teleport",
+        summary: "Jump to a file or symbol by searching the workspace",
+        argument_hint: Some("<symbol-or-path>"),
+        resume_supported: false,
+    },
+    SlashCommandSpec {
+        name: "debug-tool-call",
+        summary: "Replay the last tool call with debug details",
+        argument_hint: None,
+        resume_supported: false,
+    },
     SlashCommandSpec {
         name: "export",
         summary: "Export the current conversation to a file",
@@ -136,6 +178,23 @@ pub enum SlashCommand {
     Help,
     Status,
     Compact,
+    Bughunter {
+        scope: Option<String>,
+    },
+    Commit,
+    Pr {
+        context: Option<String>,
+    },
+    Issue {
+        context: Option<String>,
+    },
+    Ultraplan {
+        task: Option<String>,
+    },
+    Teleport {
+        target: Option<String>,
+    },
+    DebugToolCall,
     Model {
         model: Option<String>,
     },
@@ -180,6 +239,23 @@ impl SlashCommand {
             "help" => Self::Help,
             "status" => Self::Status,
             "compact" => Self::Compact,
+            "bughunter" => Self::Bughunter {
+                scope: remainder_after_command(trimmed, command),
+            },
+            "commit" => Self::Commit,
+            "pr" => Self::Pr {
+                context: remainder_after_command(trimmed, command),
+            },
+            "issue" => Self::Issue {
+                context: remainder_after_command(trimmed, command),
+            },
+            "ultraplan" => Self::Ultraplan {
+                task: remainder_after_command(trimmed, command),
+            },
+            "teleport" => Self::Teleport {
+                target: remainder_after_command(trimmed, command),
+            },
+            "debug-tool-call" => Self::DebugToolCall,
             "model" => Self::Model {
                 model: parts.next().map(ToOwned::to_owned),
             },
@@ -212,6 +288,15 @@ impl SlashCommand {
     }
 }
 
+fn remainder_after_command(input: &str, command: &str) -> Option<String> {
+    input
+        .trim()
+        .strip_prefix(&format!("/{command}"))
+        .map(str::trim)
+        .filter(|value| !value.is_empty())
+        .map(ToOwned::to_owned)
+}
+
 #[must_use]
 pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
     SLASH_COMMAND_SPECS
@@ -279,6 +364,13 @@ pub fn handle_slash_command(
             session: session.clone(),
         }),
         SlashCommand::Status
+        | SlashCommand::Bughunter { .. }
+        | SlashCommand::Commit
+        | SlashCommand::Pr { .. }
+        | SlashCommand::Issue { .. }
+        | SlashCommand::Ultraplan { .. }
+        | SlashCommand::Teleport { .. }
+        | SlashCommand::DebugToolCall
         | SlashCommand::Model { .. }
         | SlashCommand::Permissions { .. }
         | SlashCommand::Clear { .. }
@@ -307,6 +399,41 @@ mod tests {
     fn parses_supported_slash_commands() {
         assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
         assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
+        assert_eq!(
+            SlashCommand::parse("/bughunter runtime"),
+            Some(SlashCommand::Bughunter {
+                scope: Some("runtime".to_string())
+            })
+        );
+        assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit));
+        assert_eq!(
+            SlashCommand::parse("/pr ready for review"),
+            Some(SlashCommand::Pr {
+                context: Some("ready for review".to_string())
+            })
+        );
+        assert_eq!(
+            SlashCommand::parse("/issue flaky test"),
+            Some(SlashCommand::Issue {
+                context: Some("flaky test".to_string())
+            })
+        );
+        assert_eq!(
+            SlashCommand::parse("/ultraplan ship both features"),
+            Some(SlashCommand::Ultraplan {
+                task: Some("ship both features".to_string())
+            })
+        );
+        assert_eq!(
+            SlashCommand::parse("/teleport conversation.rs"),
+            Some(SlashCommand::Teleport {
+                target: Some("conversation.rs".to_string())
+            })
+        );
+        assert_eq!(
+            SlashCommand::parse("/debug-tool-call"),
+            Some(SlashCommand::DebugToolCall)
+        );
         assert_eq!(
             SlashCommand::parse("/model claude-opus"),
             Some(SlashCommand::Model {
@@ -374,6 +501,13 @@ mod tests {
         assert!(help.contains("/help"));
         assert!(help.contains("/status"));
         assert!(help.contains("/compact"));
+        assert!(help.contains("/bughunter [scope]"));
+        assert!(help.contains("/commit"));
+        assert!(help.contains("/pr [context]"));
+        assert!(help.contains("/issue [context]"));
+        assert!(help.contains("/ultraplan [task]"));
+        assert!(help.contains("/teleport <symbol-or-path>"));
+        assert!(help.contains("/debug-tool-call"));
         assert!(help.contains("/model [model]"));
         assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
         assert!(help.contains("/clear [--confirm]"));
@@ -386,7 +520,7 @@ mod tests {
         assert!(help.contains("/version"));
         assert!(help.contains("/export [file]"));
         assert!(help.contains("/session [list|switch <session-id>]"));
-        assert_eq!(slash_command_specs().len(), 15);
+        assert_eq!(slash_command_specs().len(), 22);
         assert_eq!(resume_supported_slash_commands().len(), 11);
     }
 
@@ -434,6 +568,22 @@ mod tests {
         let session = Session::new();
         assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
         assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
+        assert!(
+            handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
+        );
+        assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none());
+        assert!(handle_slash_command("/pr", &session, CompactionConfig::default()).is_none());
+        assert!(handle_slash_command("/issue", &session, CompactionConfig::default()).is_none());
+        assert!(
+            handle_slash_command("/ultraplan", &session, CompactionConfig::default()).is_none()
+        );
+        assert!(
+            handle_slash_command("/teleport foo", &session, CompactionConfig::default()).is_none()
+        );
+        assert!(
+            handle_slash_command("/debug-tool-call", &session, CompactionConfig::default())
+                .is_none()
+        );
         assert!(
             handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
         );

+ 173 - 2
rust/crates/runtime/src/conversation.rs

@@ -8,6 +8,9 @@ use crate::permissions::{PermissionOutcome, PermissionPolicy, PermissionPrompter
 use crate::session::{ContentBlock, ConversationMessage, Session};
 use crate::usage::{TokenUsage, UsageTracker};
 
+const DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD: u32 = 100_000;
+const AUTO_COMPACTION_THRESHOLD_ENV_VAR: &str = "CLAUDE_CODE_AUTO_COMPACT_INPUT_TOKENS";
+
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct ApiRequest {
     pub system_prompt: Vec<String>,
@@ -84,6 +87,12 @@ pub struct TurnSummary {
     pub tool_results: Vec<ConversationMessage>,
     pub iterations: usize,
     pub usage: TokenUsage,
+    pub auto_compaction: Option<AutoCompactionEvent>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct AutoCompactionEvent {
+    pub removed_message_count: usize,
 }
 
 pub struct ConversationRuntime<C, T> {
@@ -94,6 +103,7 @@ pub struct ConversationRuntime<C, T> {
     system_prompt: Vec<String>,
     max_iterations: usize,
     usage_tracker: UsageTracker,
+    auto_compaction_input_tokens_threshold: u32,
 }
 
 impl<C, T> ConversationRuntime<C, T>
@@ -118,6 +128,7 @@ where
             system_prompt,
             max_iterations: usize::MAX,
             usage_tracker,
+            auto_compaction_input_tokens_threshold: auto_compaction_threshold_from_env(),
         }
     }
 
@@ -127,6 +138,12 @@ where
         self
     }
 
+    #[must_use]
+    pub fn with_auto_compaction_input_tokens_threshold(mut self, threshold: u32) -> Self {
+        self.auto_compaction_input_tokens_threshold = threshold;
+        self
+    }
+
     pub fn run_turn(
         &mut self,
         user_input: impl Into<String>,
@@ -209,11 +226,14 @@ where
             }
         }
 
+        let auto_compaction = self.maybe_auto_compact();
+
         Ok(TurnSummary {
             assistant_messages,
             tool_results,
             iterations,
             usage: self.usage_tracker.cumulative_usage(),
+            auto_compaction,
         })
     }
 
@@ -241,6 +261,48 @@ where
     pub fn into_session(self) -> Session {
         self.session
     }
+
+    fn maybe_auto_compact(&mut self) -> Option<AutoCompactionEvent> {
+        if self.usage_tracker.cumulative_usage().input_tokens
+            < self.auto_compaction_input_tokens_threshold
+        {
+            return None;
+        }
+
+        let result = compact_session(
+            &self.session,
+            CompactionConfig {
+                max_estimated_tokens: 0,
+                ..CompactionConfig::default()
+            },
+        );
+
+        if result.removed_message_count == 0 {
+            return None;
+        }
+
+        self.session = result.compacted_session;
+        Some(AutoCompactionEvent {
+            removed_message_count: result.removed_message_count,
+        })
+    }
+}
+
+#[must_use]
+pub fn auto_compaction_threshold_from_env() -> u32 {
+    parse_auto_compaction_threshold(
+        std::env::var(AUTO_COMPACTION_THRESHOLD_ENV_VAR)
+            .ok()
+            .as_deref(),
+    )
+}
+
+#[must_use]
+fn parse_auto_compaction_threshold(value: Option<&str>) -> u32 {
+    value
+        .and_then(|raw| raw.trim().parse::<u32>().ok())
+        .filter(|threshold| *threshold > 0)
+        .unwrap_or(DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD)
 }
 
 fn build_assistant_message(
@@ -325,8 +387,9 @@ impl ToolExecutor for StaticToolExecutor {
 #[cfg(test)]
 mod tests {
     use super::{
-        ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError,
-        StaticToolExecutor,
+        parse_auto_compaction_threshold, ApiClient, ApiRequest, AssistantEvent,
+        AutoCompactionEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
+        DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD,
     };
     use crate::compact::CompactionConfig;
     use crate::permissions::{
@@ -436,6 +499,7 @@ mod tests {
         assert_eq!(summary.tool_results.len(), 1);
         assert_eq!(runtime.session().messages.len(), 4);
         assert_eq!(summary.usage.output_tokens, 10);
+        assert_eq!(summary.auto_compaction, None);
         assert!(matches!(
             runtime.session().messages[1].blocks[1],
             ContentBlock::ToolUse { .. }
@@ -581,4 +645,111 @@ mod tests {
             MessageRole::System
         );
     }
+
+    #[test]
+    fn auto_compacts_when_cumulative_input_threshold_is_crossed() {
+        struct SimpleApi;
+        impl ApiClient for SimpleApi {
+            fn stream(
+                &mut self,
+                _request: ApiRequest,
+            ) -> Result<Vec<AssistantEvent>, RuntimeError> {
+                Ok(vec![
+                    AssistantEvent::TextDelta("done".to_string()),
+                    AssistantEvent::Usage(TokenUsage {
+                        input_tokens: 120_000,
+                        output_tokens: 4,
+                        cache_creation_input_tokens: 0,
+                        cache_read_input_tokens: 0,
+                    }),
+                    AssistantEvent::MessageStop,
+                ])
+            }
+        }
+
+        let session = Session {
+            version: 1,
+            messages: vec![
+                crate::session::ConversationMessage::user_text("one"),
+                crate::session::ConversationMessage::assistant(vec![ContentBlock::Text {
+                    text: "two".to_string(),
+                }]),
+                crate::session::ConversationMessage::user_text("three"),
+                crate::session::ConversationMessage::assistant(vec![ContentBlock::Text {
+                    text: "four".to_string(),
+                }]),
+            ],
+        };
+
+        let mut runtime = ConversationRuntime::new(
+            session,
+            SimpleApi,
+            StaticToolExecutor::new(),
+            PermissionPolicy::new(PermissionMode::DangerFullAccess),
+            vec!["system".to_string()],
+        )
+        .with_auto_compaction_input_tokens_threshold(100_000);
+
+        let summary = runtime
+            .run_turn("trigger", None)
+            .expect("turn should succeed");
+
+        assert_eq!(
+            summary.auto_compaction,
+            Some(AutoCompactionEvent {
+                removed_message_count: 2,
+            })
+        );
+        assert_eq!(runtime.session().messages[0].role, MessageRole::System);
+    }
+
+    #[test]
+    fn skips_auto_compaction_below_threshold() {
+        struct SimpleApi;
+        impl ApiClient for SimpleApi {
+            fn stream(
+                &mut self,
+                _request: ApiRequest,
+            ) -> Result<Vec<AssistantEvent>, RuntimeError> {
+                Ok(vec![
+                    AssistantEvent::TextDelta("done".to_string()),
+                    AssistantEvent::Usage(TokenUsage {
+                        input_tokens: 99_999,
+                        output_tokens: 4,
+                        cache_creation_input_tokens: 0,
+                        cache_read_input_tokens: 0,
+                    }),
+                    AssistantEvent::MessageStop,
+                ])
+            }
+        }
+
+        let mut runtime = ConversationRuntime::new(
+            Session::new(),
+            SimpleApi,
+            StaticToolExecutor::new(),
+            PermissionPolicy::new(PermissionMode::DangerFullAccess),
+            vec!["system".to_string()],
+        )
+        .with_auto_compaction_input_tokens_threshold(100_000);
+
+        let summary = runtime
+            .run_turn("trigger", None)
+            .expect("turn should succeed");
+        assert_eq!(summary.auto_compaction, None);
+        assert_eq!(runtime.session().messages.len(), 2);
+    }
+
+    #[test]
+    fn auto_compaction_threshold_defaults_and_parses_values() {
+        assert_eq!(
+            parse_auto_compaction_threshold(None),
+            DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD
+        );
+        assert_eq!(parse_auto_compaction_threshold(Some("4321")), 4321);
+        assert_eq!(
+            parse_auto_compaction_threshold(Some("not-a-number")),
+            DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD
+        );
+    }
 }

+ 2 - 2
rust/crates/runtime/src/lib.rs

@@ -30,8 +30,8 @@ pub use config::{
     CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
 };
 pub use conversation::{
-    ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
-    ToolError, ToolExecutor, TurnSummary,
+    auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent,
+    ConversationRuntime, RuntimeError, StaticToolExecutor, ToolError, ToolExecutor, TurnSummary,
 };
 pub use file_ops::{
     edit_file, glob_search, grep_search, read_file, write_file, EditFileOutput, GlobSearchOutput,

+ 407 - 4
rust/crates/rusty-claude-cli/src/main.rs

@@ -27,8 +27,8 @@ use runtime::{
     clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
     parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest,
     AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
-    ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest,
-    OAuthConfig, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
+    ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig,
+    OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
     Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
 };
 use serde_json::json;
@@ -775,6 +775,10 @@ fn format_compact_report(removed: usize, resulting_messages: usize, skipped: boo
     }
 }
 
+fn format_auto_compaction_notice(removed: usize) -> String {
+    format!("[auto-compacted: removed {removed} messages]")
+}
+
 fn parse_git_status_metadata(status: Option<&str>) -> (Option<PathBuf>, Option<String>) {
     let Some(status) = status else {
         return (None, None);
@@ -913,7 +917,14 @@ fn run_resume_command(
                 )),
             })
         }
-        SlashCommand::Resume { .. }
+        SlashCommand::Bughunter { .. }
+        | SlashCommand::Commit
+        | SlashCommand::Pr { .. }
+        | SlashCommand::Issue { .. }
+        | SlashCommand::Ultraplan { .. }
+        | SlashCommand::Teleport { .. }
+        | SlashCommand::DebugToolCall
+        | SlashCommand::Resume { .. }
         | SlashCommand::Model { .. }
         | SlashCommand::Permissions { .. }
         | SlashCommand::Session { .. }
@@ -1050,13 +1061,19 @@ impl LiveCli {
         let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
         let result = self.runtime.run_turn(input, Some(&mut permission_prompter));
         match result {
-            Ok(_) => {
+            Ok(summary) => {
                 spinner.finish(
                     "✨ Done",
                     TerminalRenderer::new().color_theme(),
                     &mut stdout,
                 )?;
                 println!();
+                if let Some(event) = summary.auto_compaction {
+                    println!(
+                        "{}",
+                        format_auto_compaction_notice(event.removed_message_count)
+                    );
+                }
                 self.persist_session()?;
                 Ok(())
             }
@@ -1103,6 +1120,10 @@ impl LiveCli {
                 "message": final_assistant_text(&summary),
                 "model": self.model,
                 "iterations": summary.iterations,
+                "auto_compaction": summary.auto_compaction.map(|event| json!({
+                    "removed_messages": event.removed_message_count,
+                    "notice": format_auto_compaction_notice(event.removed_message_count),
+                })),
                 "tool_uses": collect_tool_uses(&summary),
                 "tool_results": collect_tool_results(&summary),
                 "usage": {
@@ -1129,6 +1150,34 @@ impl LiveCli {
                 self.print_status();
                 false
             }
+            SlashCommand::Bughunter { scope } => {
+                self.run_bughunter(scope.as_deref())?;
+                false
+            }
+            SlashCommand::Commit => {
+                self.run_commit()?;
+                true
+            }
+            SlashCommand::Pr { context } => {
+                self.run_pr(context.as_deref())?;
+                false
+            }
+            SlashCommand::Issue { context } => {
+                self.run_issue(context.as_deref())?;
+                false
+            }
+            SlashCommand::Ultraplan { task } => {
+                self.run_ultraplan(task.as_deref())?;
+                false
+            }
+            SlashCommand::Teleport { target } => {
+                self.run_teleport(target.as_deref())?;
+                false
+            }
+            SlashCommand::DebugToolCall => {
+                self.run_debug_tool_call()?;
+                false
+            }
             SlashCommand::Compact => {
                 self.compact()?;
                 false
@@ -1448,6 +1497,160 @@ impl LiveCli {
         println!("{}", format_compact_report(removed, kept, skipped));
         Ok(())
     }
+
+    fn run_internal_prompt_text(
+        &self,
+        prompt: &str,
+        enable_tools: bool,
+    ) -> Result<String, Box<dyn std::error::Error>> {
+        let session = self.runtime.session().clone();
+        let mut runtime = build_runtime(
+            session,
+            self.model.clone(),
+            self.system_prompt.clone(),
+            enable_tools,
+            false,
+            self.allowed_tools.clone(),
+            self.permission_mode,
+        )?;
+        let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
+        let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?;
+        Ok(final_assistant_text(&summary).trim().to_string())
+    }
+
+    fn run_bughunter(&self, scope: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
+        let scope = scope.unwrap_or("the current repository");
+        let prompt = format!(
+            "You are /bughunter. Inspect {scope} and identify the most likely bugs or correctness issues. Prioritize concrete findings with file paths, severity, and suggested fixes. Use tools if needed."
+        );
+        println!("{}", self.run_internal_prompt_text(&prompt, true)?);
+        Ok(())
+    }
+
+    fn run_ultraplan(&self, task: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
+        let task = task.unwrap_or("the current repo work");
+        let prompt = format!(
+            "You are /ultraplan. Produce a deep multi-step execution plan for {task}. Include goals, risks, implementation sequence, verification steps, and rollback considerations. Use tools if needed."
+        );
+        println!("{}", self.run_internal_prompt_text(&prompt, true)?);
+        Ok(())
+    }
+
+    fn run_teleport(&self, target: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
+        let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else {
+            println!("Usage: /teleport <symbol-or-path>");
+            return Ok(());
+        };
+
+        println!("{}", render_teleport_report(target)?);
+        Ok(())
+    }
+
+    fn run_debug_tool_call(&self) -> Result<(), Box<dyn std::error::Error>> {
+        println!("{}", render_last_tool_debug_report(self.runtime.session())?);
+        Ok(())
+    }
+
+    fn run_commit(&mut self) -> Result<(), Box<dyn std::error::Error>> {
+        let status = git_output(&["status", "--short"])?;
+        if status.trim().is_empty() {
+            println!("Commit\n  Result           skipped\n  Reason           no workspace changes");
+            return Ok(());
+        }
+
+        git_status_ok(&["add", "-A"])?;
+        let staged_stat = git_output(&["diff", "--cached", "--stat"])?;
+        let prompt = format!(
+            "Generate a git commit message in plain text Lore format only. Base it on this staged diff summary:\n\n{}\n\nRecent conversation context:\n{}",
+            truncate_for_prompt(&staged_stat, 8_000),
+            recent_user_context(self.runtime.session(), 6)
+        );
+        let message = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?);
+        if message.trim().is_empty() {
+            return Err("generated commit message was empty".into());
+        }
+
+        let path = write_temp_text_file("claw-commit-message.txt", &message)?;
+        let output = Command::new("git")
+            .args(["commit", "--file"])
+            .arg(&path)
+            .current_dir(env::current_dir()?)
+            .output()?;
+        if !output.status.success() {
+            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+            return Err(format!("git commit failed: {stderr}").into());
+        }
+
+        println!(
+            "Commit\n  Result           created\n  Message file     {}\n\n{}",
+            path.display(),
+            message.trim()
+        );
+        Ok(())
+    }
+
+    fn run_pr(&self, context: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
+        let staged = git_output(&["diff", "--stat"])?;
+        let prompt = format!(
+            "Generate a pull request title and body from this conversation and diff summary. Output plain text in this format exactly:\nTITLE: <title>\nBODY:\n<body markdown>\n\nContext hint: {}\n\nDiff summary:\n{}",
+            context.unwrap_or("none"),
+            truncate_for_prompt(&staged, 10_000)
+        );
+        let draft = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?);
+        let (title, body) = parse_titled_body(&draft)
+            .ok_or_else(|| "failed to parse generated PR title/body".to_string())?;
+
+        if command_exists("gh") {
+            let body_path = write_temp_text_file("claw-pr-body.md", &body)?;
+            let output = Command::new("gh")
+                .args(["pr", "create", "--title", &title, "--body-file"])
+                .arg(&body_path)
+                .current_dir(env::current_dir()?)
+                .output()?;
+            if output.status.success() {
+                let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
+                println!(
+                    "PR\n  Result           created\n  Title            {title}\n  URL              {}",
+                    if stdout.is_empty() { "<unknown>" } else { &stdout }
+                );
+                return Ok(());
+            }
+        }
+
+        println!("PR draft\n  Title            {title}\n\n{body}");
+        Ok(())
+    }
+
+    fn run_issue(&self, context: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
+        let prompt = format!(
+            "Generate a GitHub issue title and body from this conversation. Output plain text in this format exactly:\nTITLE: <title>\nBODY:\n<body markdown>\n\nContext hint: {}\n\nConversation context:\n{}",
+            context.unwrap_or("none"),
+            truncate_for_prompt(&recent_user_context(self.runtime.session(), 10), 10_000)
+        );
+        let draft = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?);
+        let (title, body) = parse_titled_body(&draft)
+            .ok_or_else(|| "failed to parse generated issue title/body".to_string())?;
+
+        if command_exists("gh") {
+            let body_path = write_temp_text_file("claw-issue-body.md", &body)?;
+            let output = Command::new("gh")
+                .args(["issue", "create", "--title", &title, "--body-file"])
+                .arg(&body_path)
+                .current_dir(env::current_dir()?)
+                .output()?;
+            if output.status.success() {
+                let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
+                println!(
+                    "Issue\n  Result           created\n  Title            {title}\n  URL              {}",
+                    if stdout.is_empty() { "<unknown>" } else { &stdout }
+                );
+                return Ok(());
+            }
+        }
+
+        println!("Issue draft\n  Title            {title}\n\n{body}");
+        Ok(())
+    }
 }
 
 fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
@@ -1799,6 +2002,206 @@ fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
     Ok(format!("Diff\n\n{}", diff.trim_end()))
 }
 
+fn render_teleport_report(target: &str) -> Result<String, Box<dyn std::error::Error>> {
+    let cwd = env::current_dir()?;
+
+    let file_list = Command::new("rg")
+        .args(["--files"])
+        .current_dir(&cwd)
+        .output()?;
+    let file_matches = if file_list.status.success() {
+        String::from_utf8(file_list.stdout)?
+            .lines()
+            .filter(|line| line.contains(target))
+            .take(10)
+            .map(ToOwned::to_owned)
+            .collect::<Vec<_>>()
+    } else {
+        Vec::new()
+    };
+
+    let content_output = Command::new("rg")
+        .args(["-n", "-S", "--color", "never", target, "."])
+        .current_dir(&cwd)
+        .output()?;
+
+    let mut lines = vec![format!("Teleport\n  Target           {target}")];
+    if !file_matches.is_empty() {
+        lines.push(String::new());
+        lines.push("File matches".to_string());
+        lines.extend(file_matches.into_iter().map(|path| format!("  {path}")));
+    }
+
+    if content_output.status.success() {
+        let matches = String::from_utf8(content_output.stdout)?;
+        if !matches.trim().is_empty() {
+            lines.push(String::new());
+            lines.push("Content matches".to_string());
+            lines.push(truncate_for_prompt(&matches, 4_000));
+        }
+    }
+
+    if lines.len() == 1 {
+        lines.push("  Result           no matches found".to_string());
+    }
+
+    Ok(lines.join("\n"))
+}
+
+fn render_last_tool_debug_report(session: &Session) -> Result<String, Box<dyn std::error::Error>> {
+    let last_tool_use = session
+        .messages
+        .iter()
+        .rev()
+        .find_map(|message| {
+            message.blocks.iter().rev().find_map(|block| match block {
+                ContentBlock::ToolUse { id, name, input } => {
+                    Some((id.clone(), name.clone(), input.clone()))
+                }
+                _ => None,
+            })
+        })
+        .ok_or_else(|| "no prior tool call found in session".to_string())?;
+
+    let tool_result = session.messages.iter().rev().find_map(|message| {
+        message.blocks.iter().rev().find_map(|block| match block {
+            ContentBlock::ToolResult {
+                tool_use_id,
+                tool_name,
+                output,
+                is_error,
+            } if tool_use_id == &last_tool_use.0 => {
+                Some((tool_name.clone(), output.clone(), *is_error))
+            }
+            _ => None,
+        })
+    });
+
+    let mut lines = vec![
+        "Debug tool call".to_string(),
+        format!("  Tool id          {}", last_tool_use.0),
+        format!("  Tool name        {}", last_tool_use.1),
+        "  Input".to_string(),
+        indent_block(&last_tool_use.2, 4),
+    ];
+
+    match tool_result {
+        Some((tool_name, output, is_error)) => {
+            lines.push("  Result".to_string());
+            lines.push(format!("    name           {tool_name}"));
+            lines.push(format!(
+                "    status         {}",
+                if is_error { "error" } else { "ok" }
+            ));
+            lines.push(indent_block(&output, 4));
+        }
+        None => lines.push("  Result           missing tool result".to_string()),
+    }
+
+    Ok(lines.join("\n"))
+}
+
+fn indent_block(value: &str, spaces: usize) -> String {
+    let indent = " ".repeat(spaces);
+    value
+        .lines()
+        .map(|line| format!("{indent}{line}"))
+        .collect::<Vec<_>>()
+        .join("\n")
+}
+
+fn git_output(args: &[&str]) -> Result<String, Box<dyn std::error::Error>> {
+    let output = Command::new("git")
+        .args(args)
+        .current_dir(env::current_dir()?)
+        .output()?;
+    if !output.status.success() {
+        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+        return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
+    }
+    Ok(String::from_utf8(output.stdout)?)
+}
+
+fn git_status_ok(args: &[&str]) -> Result<(), Box<dyn std::error::Error>> {
+    let output = Command::new("git")
+        .args(args)
+        .current_dir(env::current_dir()?)
+        .output()?;
+    if !output.status.success() {
+        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+        return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
+    }
+    Ok(())
+}
+
+fn command_exists(name: &str) -> bool {
+    Command::new("which")
+        .arg(name)
+        .output()
+        .map(|output| output.status.success())
+        .unwrap_or(false)
+}
+
+fn write_temp_text_file(
+    filename: &str,
+    contents: &str,
+) -> Result<PathBuf, Box<dyn std::error::Error>> {
+    let path = env::temp_dir().join(filename);
+    fs::write(&path, contents)?;
+    Ok(path)
+}
+
+fn recent_user_context(session: &Session, limit: usize) -> String {
+    let requests = session
+        .messages
+        .iter()
+        .filter(|message| message.role == MessageRole::User)
+        .filter_map(|message| {
+            message.blocks.iter().find_map(|block| match block {
+                ContentBlock::Text { text } => Some(text.trim().to_string()),
+                _ => None,
+            })
+        })
+        .rev()
+        .take(limit)
+        .collect::<Vec<_>>();
+
+    if requests.is_empty() {
+        "<no prior user messages>".to_string()
+    } else {
+        requests
+            .into_iter()
+            .rev()
+            .enumerate()
+            .map(|(index, text)| format!("{}. {}", index + 1, text))
+            .collect::<Vec<_>>()
+            .join("\n")
+    }
+}
+
+fn truncate_for_prompt(value: &str, limit: usize) -> String {
+    if value.chars().count() <= limit {
+        value.trim().to_string()
+    } else {
+        let truncated = value.chars().take(limit).collect::<String>();
+        format!("{}\n…[truncated]", truncated.trim_end())
+    }
+}
+
+fn sanitize_generated_message(value: &str) -> String {
+    value.trim().trim_matches('`').trim().replace("\r\n", "\n")
+}
+
+fn parse_titled_body(value: &str) -> Option<(String, String)> {
+    let normalized = sanitize_generated_message(value);
+    let title = normalized
+        .lines()
+        .find_map(|line| line.strip_prefix("TITLE:").map(str::trim))?;
+    let body_start = normalized.find("BODY:")?;
+    let body = normalized[body_start + "BODY:".len()..].trim();
+    Some((title.to_string(), body.to_string()))
+}
+
 fn render_version_report() -> String {
     let git_sha = GIT_SHA.unwrap_or("unknown");
     let target = BUILD_TARGET.unwrap_or("unknown");