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() {