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, };