From ee212c5e61a5b08196ccc6276eb353c18fa23d30 Mon Sep 17 00:00:00 2001 From: Alex Netsch Date: Fri, 16 Jan 2026 17:32:27 +0000 Subject: [PATCH] Add Claude Code Stop hook for uncommitted changes check (Vibe Kanban) (#2049) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * The implementation is complete. Here's a summary of the changes: ## Summary I added a check for commits made by the coding agent during execution. If commits were made, the cleanup script will now run. ### Changes Made **File: `crates/local-deployment/src/container.rs`** 1. **Added new method `has_commits_from_execution`** (lines 314-344): This method compares the `before_head_commit` (captured at execution start) with the current HEAD for each repo to detect if any commits were made during execution. 2. **Updated the `should_start_next` logic** (lines 477-490): The decision to run the cleanup script now checks both: - Whether uncommitted changes were just committed (`changes_committed`) - Whether the agent made any commits during execution (`has_commits_from_execution`) If either condition is true, the cleanup script runs. ### How It Works - Before: Cleanup script only ran if `try_commit_changes()` returned `true` (i.e., if there were uncommitted changes to commit) - After: Cleanup script runs if `try_commit_changes()` returned `true` **OR** if `has_commits_from_execution()` returns `true` (i.e., HEAD changed from what it was at execution start) This ensures that if a coding agent makes commits during its execution but leaves no uncommitted changes, the cleanup script will still run. * feat: add Claude Code hooks for cleanup scripts in worktrees When a workspace is created with repos that have cleanup scripts configured, automatically create .claude/settings.local.json with a "Stop" hook that runs the cleanup scripts when the agent finishes. - Add create_claude_hooks_config() to create .claude/hooks/run-cleanup.sh - Handle single-repo (agent in repo dir) vs multi-repo (agent in workspace root) - Also add has_commits_from_execution() to run cleanup when agent made commits Co-Authored-By: Claude Opus 4.5 * All tests pass. The fix is complete - the Claude Code hooks will now always be created regardless of whether repos have cleanup scripts configured. * Cleanup script changes for workspace bf914d08-cb51-49a4-89b9-ba56108c841a * Done. I've implemented the simplified `check-git-status.sh` hook using `include_str!`: 1. Created `crates/local-deployment/src/scripts/check-git-status.sh` - the script that checks for uncommitted changes and blocks the agent until they commit 2. Updated `crates/local-deployment/src/container.rs`: - Added `const CHECK_GIT_STATUS_SCRIPT: &str = include_str!("scripts/check-git-status.sh");` to embed the script at compile time - Simplified `create_claude_hooks_config()` to only write this one hook script (removed all the cleanup script logic) The hook will now be created in `.claude/hooks/check-git-status.sh` and configured in `.claude/settings.local.json` as a "Stop" hook for Claude Code workspaces. * Done. I've implemented the Stop hook via the protocol in `claude.rs`: **Summary of changes:** 1. **Created** `crates/executors/src/scripts/check-git-status.sh` - The script that checks for uncommitted changes 2. **Modified** `crates/executors/src/executors/claude.rs`: - Added `const CHECK_GIT_STATUS_SCRIPT` using `include_str!` to embed the script at compile time - Updated `get_hooks()` to always include a Stop hook with the script (using `shlex::try_quote` for proper escaping) - The Stop hook is now sent via `protocol_peer.initialize(hooks)` alongside any PreToolUse hooks 3. **Removed** from `crates/local-deployment/src/container.rs`: - Deleted `create_claude_hooks_config()` function - Deleted the const and call to create hooks - Deleted the `scripts/` directory Now the hook is sent via the protocol, so no files are created in the workspace that would show up as untracked. * Done. I've updated the implementation to use `settings.local.json` with the script inlined via `bash -c`: **Summary of changes:** 1. **Reverted `crates/executors/src/executors/claude.rs`**: - Removed `CHECK_GIT_STATUS_SCRIPT` const - Restored `get_hooks()` to only return PreToolUse hooks (no Stop hook) 2. **Moved script to `crates/local-deployment/src/scripts/check-git-status.sh`** 3. **Added `shlex = "1.3.0"` to `crates/local-deployment/Cargo.toml`** 4. **Updated `crates/local-deployment/src/container.rs`**: - Added `CHECK_GIT_STATUS_SCRIPT` const using `include_str!` - Added `create_claude_hooks_config()` function that: - Only runs for single-repo workspaces - Creates `.claude/settings.local.json` with the script inlined via `bash -c` using `shlex::try_quote` for proper escaping - No `.claude/hooks/` directory is created (script is fully inline) - Called from `create_workspace_config_files()` Since `settings.local.json` is already gitignored by Claude Code, this approach avoids the untracked file issue while keeping the script in a separate file for maintainability. * Cleanup script changes for workspace bf914d08-cb51-49a4-89b9-ba56108c841a * Done. The Claude hooks setup now only runs for Claude Code executor. **Changes made to `crates/local-deployment/src/container.rs`:** 1. Removed the call to `create_claude_hooks_config()` from `create_workspace_config_files()` (and changed parameter back to `_agent_working_dir`) 2. Added the hooks setup in `start_execution_inner()` with a check for Claude Code executor: ```rust if let Some(BaseCodingAgent::ClaudeCode) = executor_action.base_executor() { Self::create_claude_hooks_config( ¤t_dir, workspace.agent_working_dir.as_deref(), ) .await?; } ``` Now the `settings.local.json` with the Stop hook will only be created when running with the Claude Code executor, not for other executors like Codex, Gemini, etc. * Cleanup script changes for workspace bf914d08-cb51-49a4-89b9-ba56108c841a * Done. The implementation now explicitly checks the repo count: **Changes to `crates/local-deployment/src/container.rs`:** 1. **Updated call site in `start_execution_inner()`:** ```rust if let Some(BaseCodingAgent::ClaudeCode) = executor_action.base_executor() { let repos = WorkspaceRepo::find_repos_for_workspace(&self.db.pool, workspace.id).await?; if repos.len() == 1 { if let Some(agent_dir) = &workspace.agent_working_dir { Self::create_claude_hooks_config(¤t_dir.join(agent_dir)).await?; } } } ``` 2. **Simplified `create_claude_hooks_config()`:** - Now takes just `repo_dir: &Path` (the full path to the repo directory) - Removed the `agent_working_dir` parameter and internal check - The caller is responsible for ensuring this is only called for single-repo workspaces * Cleanup script changes for workspace bf914d08-cb51-49a4-89b9-ba56108c841a * Done. I've successfully implemented the Stop hook via protocol callbacks instead of the file-based approach. Here's a summary of the changes: ## Summary **Files modified:** 1. `crates/executors/src/executors/claude/client.rs`: - Added `working_dir: PathBuf` field to `ClaudeAgentClient` - Added `STOP_GIT_CHECK_CALLBACK_ID` constant - Updated `on_hook_callback()` to handle Stop git check callbacks - Added `check_git_status()` async function that checks for uncommitted changes - Added `find_git_repo_in_subdirs()` helper for multi-repo fallback 2. `crates/executors/src/executors/claude.rs`: - Updated `get_hooks()` to always include a Stop hook with callback ID - Passed `working_dir` to `ClaudeAgentClient::new()` in `spawn_internal()` - Added import for `STOP_GIT_CHECK_CALLBACK_ID` 3. `crates/local-deployment/src/container.rs`: - Removed `CHECK_GIT_STATUS_SCRIPT` constant - Removed `create_claude_hooks_config()` function - Removed the call to create file-based hooks in `start_execution_inner()` 4. `crates/local-deployment/Cargo.toml`: - Removed `shlex` dependency **Files deleted:** - `crates/local-deployment/src/scripts/check-git-status.sh` - `crates/local-deployment/src/scripts/` (empty directory) **Benefits of the new approach:** - No files created in the workspace (avoids untracked file issues) - Git status check runs dynamically via protocol callback - Supports single-repo workspaces with fallback to checking subdirectories - More portable - no dependency on bash or external scripts * Cleanup script changes for workspace bf914d08-cb51-49a4-89b9-ba56108c841a * Done. I've fixed the Stop hook response format. The changes: 1. **`check_git_status()`** now returns the correct format for Stop hooks: - When allowing stop: returns `{}` (empty object) - When blocking stop: returns `{"continue": false, "stopReason": "..."}` 2. **`on_hook_callback()`** now returns the Stop hook result directly without wrapping it in `hookSpecificOutput` The implementation now follows the Claude SDK documentation for Stop hooks, which use top-level `continue` and `stopReason` fields rather than the `hookSpecificOutput` wrapper used by PreToolUse hooks. * Done. I've updated the Stop hook response format to use `{"decision": "approve"}` or `{"decision": "block", "reason": "..."}` which matches the SDK callback format documented at code.claude.com/docs/en/hooks. * Fixed. Removed the unused `_agent_working_dir` parameter from `create_workspace_config_files` and updated both call sites. * Done. I've implemented the `RepoContext` struct to properly pass repository information from local-deployment to executors. Here's a summary of the changes: **Files modified:** 1. **`crates/executors/src/env.rs`**: - Added `RepoContext` struct with `workspace_root` and `repo_names` fields - Added `repo_paths()` method to get full paths to all repos - Added `repo_context: RepoContext` field to `ExecutionEnv` (required field) - Updated `ExecutionEnv::new()` to take `RepoContext` as parameter 2. **`crates/executors/src/executors/claude/client.rs`**: - Replaced `working_dir: PathBuf` with `repo_context: RepoContext` in `ClaudeAgentClient` - Updated `check_git_status()` to iterate over all repos and check each for uncommitted changes - Removed `find_git_repo_in_subdirs()` helper (no longer needed) 3. **`crates/executors/src/executors/claude.rs`**: - Updated `spawn_internal()` to pass `env.repo_context` to `ClaudeAgentClient` 4. **`crates/local-deployment/src/container.rs`**: - Added import for `RepoContext` - In `start_execution_inner()`: Load repos, build `RepoContext`, pass to `ExecutionEnv::new()` * Done. I've added the `commit_reminder` setting to `ClaudeCode`: 1. **Added `commit_reminder: Option` field** to the `ClaudeCode` struct in `claude.rs` 2. **Made the Stop hook conditional** - it now only registers when `commit_reminder` is `true` (defaults to `false`/off) 3. **Regenerated TypeScript types** so the frontend knows about the new field Users can enable the commit reminder by adding `"commit_reminder": true` to their profile configuration in `~/.config/vibe-kanban/profiles.json`. * Done. Removed all 10 obvious comments as requested. * Done. Now synthetic User messages (like the Stop hook feedback) will be rendered as `SystemMessage` entries in the logs. The implementation: 1. Added `is_synthetic: bool` field to `ClaudeJson::User` variant (with `#[serde(default, rename = "isSynthetic")]`) 2. When `is_synthetic` is true, any Text content in the message gets rendered as a `SystemMessage` entry * Fix tests * No findings in the latest commits I reviewed (048e3e1b5, 723ff78bd, c1caa1f73, 4af28b855, ad1334b94). Residual risk: I didn’t run tests, and the review is based on reading diffs only. Open questions / assumptions: - Assuming it’s intentional that the Stop hook is now opt-in via `commit_reminder` in `crates/executors/src/executors/claude.rs`. - Assuming workspaces always populate repo names; otherwise the Stop hook in `crates/executors/src/executors/claude/client.rs` will auto-approve when none are configured. Change summary: - Added repo context plumbed into executor env and multi-repo git status checks in `crates/executors/src/env.rs`, `crates/executors/src/executors/claude.rs`, `crates/executors/src/executors/claude/client.rs`, and `crates/local-deployment/src/container.rs`. - Introduced `commit_reminder` config and schema/types updates in `crates/executors/src/executors/claude.rs`, `shared/schemas/claude_code.json`, and `shared/types.ts`. - Render synthetic user messages as system log entries in `crates/executors/src/executors/claude.rs`. * Lint * Done. I've added the `commit_reminder` beta setting to the global Config: 1. **Backend Config** (`v8.rs`) - Added `commit_reminder: bool` field (defaults to `false`) 2. **Removed from ClaudeCode** - Removed per-profile field, now uses global setting 3. **ExecutionEnv** - Added `commit_reminder` field to pass setting to executors 4. **Container** - Reads setting from config and passes to `ExecutionEnv` 5. **Claude executor** - Uses `env.commit_reminder` when building hooks 6. **Frontend UI** - Added checkbox in Beta Features section 7. **i18n** - Added translation keys for all 6 locales (en, es, ja, ko, zh-Hans, zh-Hant) 8. **TypeScript types** - Regenerated via `pnpm run generate-types` Users can now enable the commit reminder feature from Settings > General > Beta Features. * All tests pass. Both `pnpm run lint` and `cargo test --workspace` complete successfully. --------- Co-authored-by: Claude Opus 4.5 --- crates/executors/src/env.rs | 36 ++++++++-- crates/executors/src/executors/claude.rs | 66 ++++++++++++++----- .../executors/src/executors/claude/client.rs | 57 ++++++++++++++++ crates/executors/src/executors/qa_mock.rs | 3 + crates/local-deployment/src/container.rs | 44 ++++++++++++- .../src/services/config/versions/v8.rs | 4 ++ frontend/src/i18n/locales/en/settings.json | 4 ++ frontend/src/i18n/locales/es/settings.json | 4 ++ frontend/src/i18n/locales/ja/settings.json | 4 ++ frontend/src/i18n/locales/ko/settings.json | 4 ++ .../src/i18n/locales/zh-Hans/settings.json | 4 ++ .../src/i18n/locales/zh-Hant/settings.json | 4 ++ .../src/pages/settings/GeneralSettings.tsx | 17 +++++ shared/types.ts | 2 +- 14 files changed, 230 insertions(+), 23 deletions(-) diff --git a/crates/executors/src/env.rs b/crates/executors/src/env.rs index a3e3ab4c..be3ed320 100644 --- a/crates/executors/src/env.rs +++ b/crates/executors/src/env.rs @@ -1,19 +1,47 @@ -use std::collections::HashMap; +use std::{collections::HashMap, path::PathBuf}; use tokio::process::Command; use crate::command::CmdOverrides; -/// Environment variables to inject into executor processes +/// Repository context for executor operations #[derive(Debug, Clone, Default)] +pub struct RepoContext { + pub workspace_root: PathBuf, + /// Names of repositories in the workspace (subdirectory names) + pub repo_names: Vec, +} + +impl RepoContext { + pub fn new(workspace_root: PathBuf, repo_names: Vec) -> Self { + Self { + workspace_root, + repo_names, + } + } + + pub fn repo_paths(&self) -> Vec { + self.repo_names + .iter() + .map(|name| self.workspace_root.join(name)) + .collect() + } +} + +/// Environment variables to inject into executor processes +#[derive(Debug, Clone)] pub struct ExecutionEnv { pub vars: HashMap, + pub repo_context: RepoContext, + pub commit_reminder: bool, } impl ExecutionEnv { - pub fn new() -> Self { + pub fn new(repo_context: RepoContext, commit_reminder: bool) -> Self { Self { vars: HashMap::new(), + repo_context, + commit_reminder, } } @@ -61,7 +89,7 @@ mod tests { #[test] fn profile_overrides_runtime_env() { - let mut base = ExecutionEnv::default(); + let mut base = ExecutionEnv::new(RepoContext::default(), false); base.insert("VK_PROJECT_NAME", "runtime"); base.insert("FOO", "runtime"); diff --git a/crates/executors/src/executors/claude.rs b/crates/executors/src/executors/claude.rs index 9a1e185d..cbbeb03a 100644 --- a/crates/executors/src/executors/claude.rs +++ b/crates/executors/src/executors/claude.rs @@ -18,7 +18,7 @@ use workspace_utils::{ }; use self::{ - client::{AUTO_APPROVE_CALLBACK_ID, ClaudeAgentClient}, + client::{AUTO_APPROVE_CALLBACK_ID, ClaudeAgentClient, STOP_GIT_CHECK_CALLBACK_ID}, protocol::ProtocolPeer, types::{ControlRequestType, ControlResponseType, PermissionMode}, }; @@ -128,10 +128,23 @@ impl ClaudeCode { } } - pub fn get_hooks(&self) -> Option { + pub fn get_hooks(&self, commit_reminder: bool) -> Option { + let mut hooks = serde_json::Map::new(); + + if commit_reminder { + hooks.insert( + "Stop".to_string(), + serde_json::json!([{ + "hookCallbackIds": [STOP_GIT_CHECK_CALLBACK_ID] + }]), + ); + } + + // Add PreToolUse hooks based on plan/approvals settings if self.plan.unwrap_or(false) { - Some(serde_json::json!({ - "PreToolUse": [ + hooks.insert( + "PreToolUse".to_string(), + serde_json::json!([ { "matcher": "^ExitPlanMode$", "hookCallbackIds": ["tool_approval"], @@ -140,20 +153,21 @@ impl ClaudeCode { "matcher": "^(?!ExitPlanMode$).*", "hookCallbackIds": [AUTO_APPROVE_CALLBACK_ID], } - ] - })) + ]), + ); } else if self.approvals.unwrap_or(false) { - Some(serde_json::json!({ - "PreToolUse": [ + hooks.insert( + "PreToolUse".to_string(), + serde_json::json!([ { "matcher": "^(?!(Glob|Grep|NotebookRead|Read|Task|TodoWrite)$).*", "hookCallbackIds": ["tool_approval"], } - ] - })) - } else { - None + ]), + ); } + + Some(serde_json::Value::Object(hooks)) } } @@ -271,7 +285,7 @@ impl ClaudeCode { let new_stdout = create_stdout_pipe_writer(&mut child)?; let permission_mode = self.permission_mode(); - let hooks = self.get_hooks(); + let hooks = self.get_hooks(env.commit_reminder); // Create interrupt channel for graceful shutdown let (interrupt_tx, interrupt_rx) = tokio::sync::oneshot::channel::<()>(); @@ -279,9 +293,10 @@ impl ClaudeCode { // Spawn task to handle the SDK client with control protocol let prompt_clone = combined_prompt.clone(); let approvals_clone = self.approvals_service.clone(); + let repo_context = env.repo_context.clone(); tokio::spawn(async move { let log_writer = LogWriter::new(new_stdout); - let client = ClaudeAgentClient::new(log_writer.clone(), approvals_clone); + let client = ClaudeAgentClient::new(log_writer.clone(), approvals_clone, repo_context); let protocol_peer = ProtocolPeer::spawn(child_stdin, child_stdout, client.clone(), interrupt_rx); @@ -880,7 +895,11 @@ impl ClaudeLogProcessor { } } } - ClaudeJson::User { message, .. } => { + ClaudeJson::User { + message, + is_synthetic, + .. + } => { if matches!(self.strategy, HistoryStrategy::AmpResume) && message .content @@ -912,6 +931,21 @@ impl ClaudeLogProcessor { } } + if *is_synthetic { + for item in &message.content { + if let ClaudeContentItem::Text { text } = item { + let entry = NormalizedEntry { + timestamp: None, + entry_type: NormalizedEntryType::SystemMessage, + content: text.clone(), + metadata: None, + }; + let id = entry_index_provider.next(); + patches.push(ConversationPatch::add_normalized_entry(id, entry)); + } + } + } + for item in &message.content { if let ClaudeContentItem::ToolResult { tool_use_id, @@ -1470,6 +1504,8 @@ pub enum ClaudeJson { User { message: ClaudeMessage, session_id: Option, + #[serde(default, rename = "isSynthetic")] + is_synthetic: bool, }, ToolUse { tool_name: String, diff --git a/crates/executors/src/executors/claude/client.rs b/crates/executors/src/executors/claude/client.rs index 9be8b267..d95d14aa 100644 --- a/crates/executors/src/executors/claude/client.rs +++ b/crates/executors/src/executors/claude/client.rs @@ -1,10 +1,12 @@ use std::sync::Arc; +use tokio::process::Command; use workspace_utils::approvals::ApprovalStatus; use super::types::PermissionMode; use crate::{ approvals::{ExecutorApprovalError, ExecutorApprovalService}, + env::RepoContext, executors::{ ExecutorError, claude::{ @@ -20,12 +22,14 @@ use crate::{ const EXIT_PLAN_MODE_NAME: &str = "ExitPlanMode"; pub const AUTO_APPROVE_CALLBACK_ID: &str = "AUTO_APPROVE_CALLBACK_ID"; +pub const STOP_GIT_CHECK_CALLBACK_ID: &str = "STOP_GIT_CHECK_CALLBACK_ID"; /// Claude Agent client with control protocol support pub struct ClaudeAgentClient { log_writer: LogWriter, approvals: Option>, auto_approve: bool, // true when approvals is None + repo_context: RepoContext, } impl ClaudeAgentClient { @@ -33,12 +37,14 @@ impl ClaudeAgentClient { pub fn new( log_writer: LogWriter, approvals: Option>, + repo_context: RepoContext, ) -> Arc { let auto_approve = approvals.is_none(); Arc::new(Self { log_writer, approvals, auto_approve, + repo_context, }) } @@ -149,6 +155,11 @@ impl ClaudeAgentClient { _input: serde_json::Value, _tool_use_id: Option, ) -> Result { + // Stop hook git check - uses `decision` (approve/block) and `reason` fields + if callback_id == STOP_GIT_CHECK_CALLBACK_ID { + return Ok(check_git_status(&self.repo_context).await); + } + if self.auto_approve { Ok(serde_json::json!({ "hookSpecificOutput": { @@ -187,3 +198,49 @@ impl ClaudeAgentClient { self.log_writer.log_raw(line).await } } + +/// Check for uncommitted git changes across all repos in the workspace. +/// Returns a Stop hook response using `decision` (approve/block) and `reason` fields. +async fn check_git_status(repo_context: &RepoContext) -> serde_json::Value { + let repo_paths = repo_context.repo_paths(); + + if repo_paths.is_empty() { + return serde_json::json!({"decision": "approve"}); + } + + let mut all_status = String::new(); + + for repo_path in &repo_paths { + if !repo_path.join(".git").exists() { + continue; + } + + let output = Command::new("git") + .args(["status", "--porcelain"]) + .current_dir(repo_path) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .await; + + if let Ok(out) = output + && !out.stdout.is_empty() + { + let status = String::from_utf8_lossy(&out.stdout); + all_status.push_str(&format!("\n{}:\n{}", repo_path.display(), status)); + } + } + + if all_status.is_empty() { + // No uncommitted changes in any repo + serde_json::json!({"decision": "approve"}) + } else { + // Has uncommitted changes, block stop + serde_json::json!({ + "decision": "block", + "reason": format!( + "There are uncommitted changes. Please stage and commit them now with a descriptive commit message.{}", + all_status + ) + }) + } +} diff --git a/crates/executors/src/executors/qa_mock.rs b/crates/executors/src/executors/qa_mock.rs index 6875b45b..50b91236 100644 --- a/crates/executors/src/executors/qa_mock.rs +++ b/crates/executors/src/executors/qa_mock.rs @@ -238,6 +238,7 @@ fn generate_mock_logs(prompt: &str) -> Vec { }], stop_reason: None, }, + is_synthetic: false, session_id: Some(session_id.clone()), }, // 5. Write tool use @@ -273,6 +274,7 @@ fn generate_mock_logs(prompt: &str) -> Vec { stop_reason: None, }, session_id: Some(session_id.clone()), + is_synthetic: false, }, // 7. Bash tool use ClaudeJson::Assistant { @@ -306,6 +308,7 @@ fn generate_mock_logs(prompt: &str) -> Vec { }], stop_reason: None, }, + is_synthetic: false, session_id: Some(session_id.clone()), }, // 9. Assistant final message diff --git a/crates/local-deployment/src/container.rs b/crates/local-deployment/src/container.rs index 1c92542d..519921ee 100644 --- a/crates/local-deployment/src/container.rs +++ b/crates/local-deployment/src/container.rs @@ -33,7 +33,7 @@ use executors::{ coding_agent_initial::CodingAgentInitialRequest, }, approvals::{ExecutorApprovalService, NoopExecutorApprovalService}, - env::ExecutionEnv, + env::{ExecutionEnv, RepoContext}, executors::{BaseCodingAgent, ExecutorExitResult, ExecutorExitSignal, InterruptSender}, logs::{NormalizedEntryType, utils::patch::extract_normalized_entry_from_patch}, profile::ExecutorProfileId, @@ -311,6 +311,35 @@ impl LocalContainerService { Ok(repos_with_changes) } + async fn has_commits_from_execution( + &self, + ctx: &ExecutionContext, + ) -> Result { + let workspace_root = self.workspace_to_current_dir(&ctx.workspace); + + let repo_states = ExecutionProcessRepoState::find_by_execution_process_id( + &self.db.pool, + ctx.execution_process.id, + ) + .await?; + + for repo in &ctx.repos { + let repo_path = workspace_root.join(&repo.name); + let current_head = self.git().get_head_info(&repo_path).ok().map(|h| h.oid); + + let before_head = repo_states + .iter() + .find(|s| s.repo_id == repo.id) + .and_then(|s| s.before_head_commit.clone()); + + if current_head != before_head { + return Ok(true); + } + } + + Ok(false) + } + /// Commit changes to each repo. Logs failures but continues with other repos. fn commit_repos(&self, repos_with_changes: Vec<(Repo, PathBuf)>, message: &str) -> bool { let mut any_committed = false; @@ -445,7 +474,12 @@ impl LocalContainerService { ctx.execution_process.run_reason, ExecutionProcessRunReason::CodingAgent ) { + // Check if agent made commits OR if we just committed uncommitted changes changes_committed + || container + .has_commits_from_execution(&ctx) + .await + .unwrap_or(false) } else { true }; @@ -1077,8 +1111,12 @@ impl ContainerService for LocalContainerService { _ => Arc::new(NoopExecutorApprovalService {}), }; - // Build ExecutionEnv with VK_* variables - let mut env = ExecutionEnv::new(); + let repos = WorkspaceRepo::find_repos_for_workspace(&self.db.pool, workspace.id).await?; + let repo_names: Vec = repos.iter().map(|r| r.name.clone()).collect(); + let repo_context = RepoContext::new(current_dir.clone(), repo_names); + + let commit_reminder = self.config.read().await.commit_reminder; + let mut env = ExecutionEnv::new(repo_context, commit_reminder); // Load task and project context for environment variables let task = workspace diff --git a/crates/services/src/services/config/versions/v8.rs b/crates/services/src/services/config/versions/v8.rs index 4430df90..2603c8f1 100644 --- a/crates/services/src/services/config/versions/v8.rs +++ b/crates/services/src/services/config/versions/v8.rs @@ -45,6 +45,8 @@ pub struct Config { pub beta_workspaces: bool, #[serde(default)] pub beta_workspaces_invitation_sent: bool, + #[serde(default)] + pub commit_reminder: bool, } impl Config { @@ -72,6 +74,7 @@ impl Config { pr_auto_description_prompt: None, beta_workspaces: false, beta_workspaces_invitation_sent: false, + commit_reminder: false, } } @@ -124,6 +127,7 @@ impl Default for Config { pr_auto_description_prompt: None, beta_workspaces: false, beta_workspaces_invitation_sent: false, + commit_reminder: false, } } } diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index 96ee5c0d..1089a89f 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -239,6 +239,10 @@ "workspaces": { "label": "Enable Workspaces Beta", "helper": "Use the new workspaces interface when viewing task attempts. Tasks will open in task view first, and attempts will open in the new workspaces view." + }, + "commitReminder": { + "label": "Commit reminder", + "helper": "Prompt supported agents to commit changes before stopping." } } }, diff --git a/frontend/src/i18n/locales/es/settings.json b/frontend/src/i18n/locales/es/settings.json index 5d555a68..0c42c755 100644 --- a/frontend/src/i18n/locales/es/settings.json +++ b/frontend/src/i18n/locales/es/settings.json @@ -239,6 +239,10 @@ "workspaces": { "label": "Habilitar Beta de Workspaces", "helper": "Usa la nueva interfaz de workspaces al ver intentos de tareas. Las tareas se abrirán primero en la vista de tareas, y los intentos se abrirán en la nueva vista de workspaces." + }, + "commitReminder": { + "label": "Recordatorio de commit", + "helper": "Solicitar a los agentes compatibles que confirmen los cambios antes de detenerse." } } }, diff --git a/frontend/src/i18n/locales/ja/settings.json b/frontend/src/i18n/locales/ja/settings.json index 7483e577..71fd03af 100644 --- a/frontend/src/i18n/locales/ja/settings.json +++ b/frontend/src/i18n/locales/ja/settings.json @@ -239,6 +239,10 @@ "workspaces": { "label": "ワークスペースベータを有効化", "helper": "タスク試行を表示する際に新しいワークスペースインターフェースを使用します。タスクは最初にタスクビューで開き、試行は新しいワークスペースビューで開きます。" + }, + "commitReminder": { + "label": "コミットリマインダー", + "helper": "対応エージェントに停止前の変更コミットを促します。" } } }, diff --git a/frontend/src/i18n/locales/ko/settings.json b/frontend/src/i18n/locales/ko/settings.json index 70d269a2..4644299e 100644 --- a/frontend/src/i18n/locales/ko/settings.json +++ b/frontend/src/i18n/locales/ko/settings.json @@ -239,6 +239,10 @@ "workspaces": { "label": "워크스페이스 베타 활성화", "helper": "작업 시도를 볼 때 새로운 워크스페이스 인터페이스를 사용합니다. 작업은 먼저 작업 보기에서 열리고, 시도는 새로운 워크스페이스 보기에서 열립니다." + }, + "commitReminder": { + "label": "커밋 알림", + "helper": "지원되는 에이전트에게 중지 전 변경사항 커밋을 요청합니다." } } }, diff --git a/frontend/src/i18n/locales/zh-Hans/settings.json b/frontend/src/i18n/locales/zh-Hans/settings.json index 3b55ef4d..16700a8c 100644 --- a/frontend/src/i18n/locales/zh-Hans/settings.json +++ b/frontend/src/i18n/locales/zh-Hans/settings.json @@ -239,6 +239,10 @@ "workspaces": { "label": "启用工作区 Beta", "helper": "查看任务尝试时使用新的工作区界面。任务将首先在任务视图中打开,尝试将在新的工作区视图中打开。" + }, + "commitReminder": { + "label": "提交提醒", + "helper": "提示支持的代理在停止前提交更改。" } } }, diff --git a/frontend/src/i18n/locales/zh-Hant/settings.json b/frontend/src/i18n/locales/zh-Hant/settings.json index d8f1d190..0638d9c2 100644 --- a/frontend/src/i18n/locales/zh-Hant/settings.json +++ b/frontend/src/i18n/locales/zh-Hant/settings.json @@ -239,6 +239,10 @@ "workspaces": { "label": "啟用工作區 Beta", "helper": "查看任務嘗試時使用新的工作區介面。任務將首先在任務檢視中開啟,嘗試將在新的工作區檢視中開啟。" + }, + "commitReminder": { + "label": "提交提醒", + "helper": "提示支援的代理在停止前提交變更。" } } }, diff --git a/frontend/src/pages/settings/GeneralSettings.tsx b/frontend/src/pages/settings/GeneralSettings.tsx index 448721a5..b8efa681 100644 --- a/frontend/src/pages/settings/GeneralSettings.tsx +++ b/frontend/src/pages/settings/GeneralSettings.tsx @@ -738,6 +738,23 @@ export function GeneralSettings() {

+
+ + updateDraft({ commit_reminder: checked }) + } + /> +
+ +

+ {t('settings.general.beta.commitReminder.helper')} +

+
+
diff --git a/shared/types.ts b/shared/types.ts index 89f9989a..12e3f39d 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -374,7 +374,7 @@ export type DirectoryListResponse = { entries: Array, current_pa export type SearchMode = "taskform" | "settings"; -export type Config = { config_version: string, theme: ThemeMode, executor_profile: ExecutorProfileId, disclaimer_acknowledged: boolean, onboarding_acknowledged: boolean, notifications: NotificationConfig, editor: EditorConfig, github: GitHubConfig, analytics_enabled: boolean, workspace_dir: string | null, last_app_version: string | null, show_release_notes: boolean, language: UiLanguage, git_branch_prefix: string, showcases: ShowcaseState, pr_auto_description_enabled: boolean, pr_auto_description_prompt: string | null, beta_workspaces: boolean, beta_workspaces_invitation_sent: boolean, }; +export type Config = { config_version: string, theme: ThemeMode, executor_profile: ExecutorProfileId, disclaimer_acknowledged: boolean, onboarding_acknowledged: boolean, notifications: NotificationConfig, editor: EditorConfig, github: GitHubConfig, analytics_enabled: boolean, workspace_dir: string | null, last_app_version: string | null, show_release_notes: boolean, language: UiLanguage, git_branch_prefix: string, showcases: ShowcaseState, pr_auto_description_enabled: boolean, pr_auto_description_prompt: string | null, beta_workspaces: boolean, beta_workspaces_invitation_sent: boolean, commit_reminder: boolean, }; export type NotificationConfig = { sound_enabled: boolean, push_enabled: boolean, sound_file: SoundFile, };