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