Add Claude Code Stop hook for uncommitted changes check (Vibe Kanban) (#2049)
* 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 <noreply@anthropic.com> * 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<bool>` 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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,47 @@
|
|||||||
use std::collections::HashMap;
|
use std::{collections::HashMap, path::PathBuf};
|
||||||
|
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
use crate::command::CmdOverrides;
|
use crate::command::CmdOverrides;
|
||||||
|
|
||||||
/// Environment variables to inject into executor processes
|
/// Repository context for executor operations
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct RepoContext {
|
||||||
|
pub workspace_root: PathBuf,
|
||||||
|
/// Names of repositories in the workspace (subdirectory names)
|
||||||
|
pub repo_names: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepoContext {
|
||||||
|
pub fn new(workspace_root: PathBuf, repo_names: Vec<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
workspace_root,
|
||||||
|
repo_names,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn repo_paths(&self) -> Vec<PathBuf> {
|
||||||
|
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 struct ExecutionEnv {
|
||||||
pub vars: HashMap<String, String>,
|
pub vars: HashMap<String, String>,
|
||||||
|
pub repo_context: RepoContext,
|
||||||
|
pub commit_reminder: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExecutionEnv {
|
impl ExecutionEnv {
|
||||||
pub fn new() -> Self {
|
pub fn new(repo_context: RepoContext, commit_reminder: bool) -> Self {
|
||||||
Self {
|
Self {
|
||||||
vars: HashMap::new(),
|
vars: HashMap::new(),
|
||||||
|
repo_context,
|
||||||
|
commit_reminder,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +89,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn profile_overrides_runtime_env() {
|
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("VK_PROJECT_NAME", "runtime");
|
||||||
base.insert("FOO", "runtime");
|
base.insert("FOO", "runtime");
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use workspace_utils::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use self::{
|
use self::{
|
||||||
client::{AUTO_APPROVE_CALLBACK_ID, ClaudeAgentClient},
|
client::{AUTO_APPROVE_CALLBACK_ID, ClaudeAgentClient, STOP_GIT_CHECK_CALLBACK_ID},
|
||||||
protocol::ProtocolPeer,
|
protocol::ProtocolPeer,
|
||||||
types::{ControlRequestType, ControlResponseType, PermissionMode},
|
types::{ControlRequestType, ControlResponseType, PermissionMode},
|
||||||
};
|
};
|
||||||
@@ -128,10 +128,23 @@ impl ClaudeCode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_hooks(&self) -> Option<serde_json::Value> {
|
pub fn get_hooks(&self, commit_reminder: bool) -> Option<serde_json::Value> {
|
||||||
|
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) {
|
if self.plan.unwrap_or(false) {
|
||||||
Some(serde_json::json!({
|
hooks.insert(
|
||||||
"PreToolUse": [
|
"PreToolUse".to_string(),
|
||||||
|
serde_json::json!([
|
||||||
{
|
{
|
||||||
"matcher": "^ExitPlanMode$",
|
"matcher": "^ExitPlanMode$",
|
||||||
"hookCallbackIds": ["tool_approval"],
|
"hookCallbackIds": ["tool_approval"],
|
||||||
@@ -140,20 +153,21 @@ impl ClaudeCode {
|
|||||||
"matcher": "^(?!ExitPlanMode$).*",
|
"matcher": "^(?!ExitPlanMode$).*",
|
||||||
"hookCallbackIds": [AUTO_APPROVE_CALLBACK_ID],
|
"hookCallbackIds": [AUTO_APPROVE_CALLBACK_ID],
|
||||||
}
|
}
|
||||||
]
|
]),
|
||||||
}))
|
);
|
||||||
} else if self.approvals.unwrap_or(false) {
|
} else if self.approvals.unwrap_or(false) {
|
||||||
Some(serde_json::json!({
|
hooks.insert(
|
||||||
"PreToolUse": [
|
"PreToolUse".to_string(),
|
||||||
|
serde_json::json!([
|
||||||
{
|
{
|
||||||
"matcher": "^(?!(Glob|Grep|NotebookRead|Read|Task|TodoWrite)$).*",
|
"matcher": "^(?!(Glob|Grep|NotebookRead|Read|Task|TodoWrite)$).*",
|
||||||
"hookCallbackIds": ["tool_approval"],
|
"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 new_stdout = create_stdout_pipe_writer(&mut child)?;
|
||||||
let permission_mode = self.permission_mode();
|
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
|
// Create interrupt channel for graceful shutdown
|
||||||
let (interrupt_tx, interrupt_rx) = tokio::sync::oneshot::channel::<()>();
|
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
|
// Spawn task to handle the SDK client with control protocol
|
||||||
let prompt_clone = combined_prompt.clone();
|
let prompt_clone = combined_prompt.clone();
|
||||||
let approvals_clone = self.approvals_service.clone();
|
let approvals_clone = self.approvals_service.clone();
|
||||||
|
let repo_context = env.repo_context.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let log_writer = LogWriter::new(new_stdout);
|
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 =
|
let protocol_peer =
|
||||||
ProtocolPeer::spawn(child_stdin, child_stdout, client.clone(), interrupt_rx);
|
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)
|
if matches!(self.strategy, HistoryStrategy::AmpResume)
|
||||||
&& message
|
&& message
|
||||||
.content
|
.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 {
|
for item in &message.content {
|
||||||
if let ClaudeContentItem::ToolResult {
|
if let ClaudeContentItem::ToolResult {
|
||||||
tool_use_id,
|
tool_use_id,
|
||||||
@@ -1470,6 +1504,8 @@ pub enum ClaudeJson {
|
|||||||
User {
|
User {
|
||||||
message: ClaudeMessage,
|
message: ClaudeMessage,
|
||||||
session_id: Option<String>,
|
session_id: Option<String>,
|
||||||
|
#[serde(default, rename = "isSynthetic")]
|
||||||
|
is_synthetic: bool,
|
||||||
},
|
},
|
||||||
ToolUse {
|
ToolUse {
|
||||||
tool_name: String,
|
tool_name: String,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tokio::process::Command;
|
||||||
use workspace_utils::approvals::ApprovalStatus;
|
use workspace_utils::approvals::ApprovalStatus;
|
||||||
|
|
||||||
use super::types::PermissionMode;
|
use super::types::PermissionMode;
|
||||||
use crate::{
|
use crate::{
|
||||||
approvals::{ExecutorApprovalError, ExecutorApprovalService},
|
approvals::{ExecutorApprovalError, ExecutorApprovalService},
|
||||||
|
env::RepoContext,
|
||||||
executors::{
|
executors::{
|
||||||
ExecutorError,
|
ExecutorError,
|
||||||
claude::{
|
claude::{
|
||||||
@@ -20,12 +22,14 @@ use crate::{
|
|||||||
|
|
||||||
const EXIT_PLAN_MODE_NAME: &str = "ExitPlanMode";
|
const EXIT_PLAN_MODE_NAME: &str = "ExitPlanMode";
|
||||||
pub const AUTO_APPROVE_CALLBACK_ID: &str = "AUTO_APPROVE_CALLBACK_ID";
|
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
|
/// Claude Agent client with control protocol support
|
||||||
pub struct ClaudeAgentClient {
|
pub struct ClaudeAgentClient {
|
||||||
log_writer: LogWriter,
|
log_writer: LogWriter,
|
||||||
approvals: Option<Arc<dyn ExecutorApprovalService>>,
|
approvals: Option<Arc<dyn ExecutorApprovalService>>,
|
||||||
auto_approve: bool, // true when approvals is None
|
auto_approve: bool, // true when approvals is None
|
||||||
|
repo_context: RepoContext,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClaudeAgentClient {
|
impl ClaudeAgentClient {
|
||||||
@@ -33,12 +37,14 @@ impl ClaudeAgentClient {
|
|||||||
pub fn new(
|
pub fn new(
|
||||||
log_writer: LogWriter,
|
log_writer: LogWriter,
|
||||||
approvals: Option<Arc<dyn ExecutorApprovalService>>,
|
approvals: Option<Arc<dyn ExecutorApprovalService>>,
|
||||||
|
repo_context: RepoContext,
|
||||||
) -> Arc<Self> {
|
) -> Arc<Self> {
|
||||||
let auto_approve = approvals.is_none();
|
let auto_approve = approvals.is_none();
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
log_writer,
|
log_writer,
|
||||||
approvals,
|
approvals,
|
||||||
auto_approve,
|
auto_approve,
|
||||||
|
repo_context,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +155,11 @@ impl ClaudeAgentClient {
|
|||||||
_input: serde_json::Value,
|
_input: serde_json::Value,
|
||||||
_tool_use_id: Option<String>,
|
_tool_use_id: Option<String>,
|
||||||
) -> Result<serde_json::Value, ExecutorError> {
|
) -> Result<serde_json::Value, ExecutorError> {
|
||||||
|
// 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 {
|
if self.auto_approve {
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"hookSpecificOutput": {
|
"hookSpecificOutput": {
|
||||||
@@ -187,3 +198,49 @@ impl ClaudeAgentClient {
|
|||||||
self.log_writer.log_raw(line).await
|
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
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ fn generate_mock_logs(prompt: &str) -> Vec<String> {
|
|||||||
}],
|
}],
|
||||||
stop_reason: None,
|
stop_reason: None,
|
||||||
},
|
},
|
||||||
|
is_synthetic: false,
|
||||||
session_id: Some(session_id.clone()),
|
session_id: Some(session_id.clone()),
|
||||||
},
|
},
|
||||||
// 5. Write tool use
|
// 5. Write tool use
|
||||||
@@ -273,6 +274,7 @@ fn generate_mock_logs(prompt: &str) -> Vec<String> {
|
|||||||
stop_reason: None,
|
stop_reason: None,
|
||||||
},
|
},
|
||||||
session_id: Some(session_id.clone()),
|
session_id: Some(session_id.clone()),
|
||||||
|
is_synthetic: false,
|
||||||
},
|
},
|
||||||
// 7. Bash tool use
|
// 7. Bash tool use
|
||||||
ClaudeJson::Assistant {
|
ClaudeJson::Assistant {
|
||||||
@@ -306,6 +308,7 @@ fn generate_mock_logs(prompt: &str) -> Vec<String> {
|
|||||||
}],
|
}],
|
||||||
stop_reason: None,
|
stop_reason: None,
|
||||||
},
|
},
|
||||||
|
is_synthetic: false,
|
||||||
session_id: Some(session_id.clone()),
|
session_id: Some(session_id.clone()),
|
||||||
},
|
},
|
||||||
// 9. Assistant final message
|
// 9. Assistant final message
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ use executors::{
|
|||||||
coding_agent_initial::CodingAgentInitialRequest,
|
coding_agent_initial::CodingAgentInitialRequest,
|
||||||
},
|
},
|
||||||
approvals::{ExecutorApprovalService, NoopExecutorApprovalService},
|
approvals::{ExecutorApprovalService, NoopExecutorApprovalService},
|
||||||
env::ExecutionEnv,
|
env::{ExecutionEnv, RepoContext},
|
||||||
executors::{BaseCodingAgent, ExecutorExitResult, ExecutorExitSignal, InterruptSender},
|
executors::{BaseCodingAgent, ExecutorExitResult, ExecutorExitSignal, InterruptSender},
|
||||||
logs::{NormalizedEntryType, utils::patch::extract_normalized_entry_from_patch},
|
logs::{NormalizedEntryType, utils::patch::extract_normalized_entry_from_patch},
|
||||||
profile::ExecutorProfileId,
|
profile::ExecutorProfileId,
|
||||||
@@ -311,6 +311,35 @@ impl LocalContainerService {
|
|||||||
Ok(repos_with_changes)
|
Ok(repos_with_changes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn has_commits_from_execution(
|
||||||
|
&self,
|
||||||
|
ctx: &ExecutionContext,
|
||||||
|
) -> Result<bool, ContainerError> {
|
||||||
|
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.
|
/// 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 {
|
fn commit_repos(&self, repos_with_changes: Vec<(Repo, PathBuf)>, message: &str) -> bool {
|
||||||
let mut any_committed = false;
|
let mut any_committed = false;
|
||||||
@@ -445,7 +474,12 @@ impl LocalContainerService {
|
|||||||
ctx.execution_process.run_reason,
|
ctx.execution_process.run_reason,
|
||||||
ExecutionProcessRunReason::CodingAgent
|
ExecutionProcessRunReason::CodingAgent
|
||||||
) {
|
) {
|
||||||
|
// Check if agent made commits OR if we just committed uncommitted changes
|
||||||
changes_committed
|
changes_committed
|
||||||
|
|| container
|
||||||
|
.has_commits_from_execution(&ctx)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
};
|
};
|
||||||
@@ -1077,8 +1111,12 @@ impl ContainerService for LocalContainerService {
|
|||||||
_ => Arc::new(NoopExecutorApprovalService {}),
|
_ => Arc::new(NoopExecutorApprovalService {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build ExecutionEnv with VK_* variables
|
let repos = WorkspaceRepo::find_repos_for_workspace(&self.db.pool, workspace.id).await?;
|
||||||
let mut env = ExecutionEnv::new();
|
let repo_names: Vec<String> = 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
|
// Load task and project context for environment variables
|
||||||
let task = workspace
|
let task = workspace
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ pub struct Config {
|
|||||||
pub beta_workspaces: bool,
|
pub beta_workspaces: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub beta_workspaces_invitation_sent: bool,
|
pub beta_workspaces_invitation_sent: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub commit_reminder: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -72,6 +74,7 @@ impl Config {
|
|||||||
pr_auto_description_prompt: None,
|
pr_auto_description_prompt: None,
|
||||||
beta_workspaces: false,
|
beta_workspaces: false,
|
||||||
beta_workspaces_invitation_sent: false,
|
beta_workspaces_invitation_sent: false,
|
||||||
|
commit_reminder: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +127,7 @@ impl Default for Config {
|
|||||||
pr_auto_description_prompt: None,
|
pr_auto_description_prompt: None,
|
||||||
beta_workspaces: false,
|
beta_workspaces: false,
|
||||||
beta_workspaces_invitation_sent: false,
|
beta_workspaces_invitation_sent: false,
|
||||||
|
commit_reminder: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,6 +239,10 @@
|
|||||||
"workspaces": {
|
"workspaces": {
|
||||||
"label": "Enable Workspaces Beta",
|
"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."
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -239,6 +239,10 @@
|
|||||||
"workspaces": {
|
"workspaces": {
|
||||||
"label": "Habilitar Beta de 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."
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -239,6 +239,10 @@
|
|||||||
"workspaces": {
|
"workspaces": {
|
||||||
"label": "ワークスペースベータを有効化",
|
"label": "ワークスペースベータを有効化",
|
||||||
"helper": "タスク試行を表示する際に新しいワークスペースインターフェースを使用します。タスクは最初にタスクビューで開き、試行は新しいワークスペースビューで開きます。"
|
"helper": "タスク試行を表示する際に新しいワークスペースインターフェースを使用します。タスクは最初にタスクビューで開き、試行は新しいワークスペースビューで開きます。"
|
||||||
|
},
|
||||||
|
"commitReminder": {
|
||||||
|
"label": "コミットリマインダー",
|
||||||
|
"helper": "対応エージェントに停止前の変更コミットを促します。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -239,6 +239,10 @@
|
|||||||
"workspaces": {
|
"workspaces": {
|
||||||
"label": "워크스페이스 베타 활성화",
|
"label": "워크스페이스 베타 활성화",
|
||||||
"helper": "작업 시도를 볼 때 새로운 워크스페이스 인터페이스를 사용합니다. 작업은 먼저 작업 보기에서 열리고, 시도는 새로운 워크스페이스 보기에서 열립니다."
|
"helper": "작업 시도를 볼 때 새로운 워크스페이스 인터페이스를 사용합니다. 작업은 먼저 작업 보기에서 열리고, 시도는 새로운 워크스페이스 보기에서 열립니다."
|
||||||
|
},
|
||||||
|
"commitReminder": {
|
||||||
|
"label": "커밋 알림",
|
||||||
|
"helper": "지원되는 에이전트에게 중지 전 변경사항 커밋을 요청합니다."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -239,6 +239,10 @@
|
|||||||
"workspaces": {
|
"workspaces": {
|
||||||
"label": "启用工作区 Beta",
|
"label": "启用工作区 Beta",
|
||||||
"helper": "查看任务尝试时使用新的工作区界面。任务将首先在任务视图中打开,尝试将在新的工作区视图中打开。"
|
"helper": "查看任务尝试时使用新的工作区界面。任务将首先在任务视图中打开,尝试将在新的工作区视图中打开。"
|
||||||
|
},
|
||||||
|
"commitReminder": {
|
||||||
|
"label": "提交提醒",
|
||||||
|
"helper": "提示支持的代理在停止前提交更改。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -239,6 +239,10 @@
|
|||||||
"workspaces": {
|
"workspaces": {
|
||||||
"label": "啟用工作區 Beta",
|
"label": "啟用工作區 Beta",
|
||||||
"helper": "查看任務嘗試時使用新的工作區介面。任務將首先在任務檢視中開啟,嘗試將在新的工作區檢視中開啟。"
|
"helper": "查看任務嘗試時使用新的工作區介面。任務將首先在任務檢視中開啟,嘗試將在新的工作區檢視中開啟。"
|
||||||
|
},
|
||||||
|
"commitReminder": {
|
||||||
|
"label": "提交提醒",
|
||||||
|
"helper": "提示支援的代理在停止前提交變更。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -738,6 +738,23 @@ export function GeneralSettings() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="commit-reminder"
|
||||||
|
checked={draft?.commit_reminder ?? false}
|
||||||
|
onCheckedChange={(checked: boolean) =>
|
||||||
|
updateDraft({ commit_reminder: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="commit-reminder" className="cursor-pointer">
|
||||||
|
{t('settings.general.beta.commitReminder.label')}
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('settings.general.beta.commitReminder.helper')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -374,7 +374,7 @@ export type DirectoryListResponse = { entries: Array<DirectoryEntry>, current_pa
|
|||||||
|
|
||||||
export type SearchMode = "taskform" | "settings";
|
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, };
|
export type NotificationConfig = { sound_enabled: boolean, push_enabled: boolean, sound_file: SoundFile, };
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user