From 292bf50f501ff07b7119b0da83d6955532635adb Mon Sep 17 00:00:00 2001
From: Hikari
Date: Tue, 10 Mar 2026 11:08:58 -0700
Subject: [PATCH] feat: add cron tool support and CLAUDE_CODE_DISABLE_CRON
setting
Ticket 201
- Added format_tool_description entries for CronCreate, CronDelete,
and CronList tools
- Added disable_cron field to ClaudeStartOptions and HikariConfig
- Pass CLAUDE_CODE_DISABLE_CRON=1 env var in both WSL and native
spawn paths when disable_cron is enabled
- Added disable cron toggle to ConfigSidebar settings UI
---
src-tauri/src/config.rs | 10 +++
src-tauri/src/wsl_bridge.rs | 109 ++++++++++++++++++++++++
src/lib/components/ConfigSidebar.svelte | 17 ++++
3 files changed, 136 insertions(+)
diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs
index dedae81..846ec3a 100644
--- a/src-tauri/src/config.rs
+++ b/src-tauri/src/config.rs
@@ -34,6 +34,9 @@ pub struct ClaudeStartOptions {
#[serde(default)]
pub max_output_tokens: Option,
+
+ #[serde(default)]
+ pub disable_cron: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -168,6 +171,9 @@ pub struct HikariConfig {
#[serde(default)]
pub task_loop_include_summary: bool,
+
+ #[serde(default)]
+ pub disable_cron: bool,
}
impl Default for HikariConfig {
@@ -214,6 +220,7 @@ impl Default for HikariConfig {
task_loop_auto_commit: false,
task_loop_commit_prefix: "feat".to_string(),
task_loop_include_summary: false,
+ disable_cron: false,
}
}
}
@@ -352,6 +359,7 @@ mod tests {
assert!(!config.task_loop_auto_commit);
assert_eq!(config.task_loop_commit_prefix, "feat");
assert!(!config.task_loop_include_summary);
+ assert!(!config.disable_cron);
}
#[test]
@@ -398,6 +406,7 @@ mod tests {
task_loop_auto_commit: true,
task_loop_commit_prefix: "fix".to_string(),
task_loop_include_summary: true,
+ disable_cron: true,
};
let json = serde_json::to_string(&config).unwrap();
@@ -415,6 +424,7 @@ mod tests {
assert!(deserialized.task_loop_auto_commit);
assert_eq!(deserialized.task_loop_commit_prefix, "fix");
assert!(deserialized.task_loop_include_summary);
+ assert!(deserialized.disable_cron);
}
#[test]
diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs
index 945a11d..ebd97c1 100644
--- a/src-tauri/src/wsl_bridge.rs
+++ b/src-tauri/src/wsl_bridge.rs
@@ -310,6 +310,11 @@ impl WslBridge {
cmd.env("CLAUDE_CODE_MAX_OUTPUT_TOKENS", max_tokens.to_string());
}
+ // Disable cron scheduling if requested
+ if options.disable_cron {
+ cmd.env("CLAUDE_CODE_DISABLE_CRON", "1");
+ }
+
cmd
} else {
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded
@@ -362,6 +367,11 @@ impl WslBridge {
claude_cmd.push_str(&format!("CLAUDE_CODE_MAX_OUTPUT_TOKENS={} ", max_tokens));
}
+ // Disable cron scheduling if requested
+ if options.disable_cron {
+ claude_cmd.push_str("CLAUDE_CODE_DISABLE_CRON=1 ");
+ }
+
claude_cmd.push_str(
"claude --output-format stream-json --input-format stream-json --verbose",
);
@@ -1128,6 +1138,12 @@ fn process_json_line(
}
ClaudeMessage::Assistant { message, parent_tool_use_id } => {
+ // Claude is actively responding — reset the watchdog timer so a long multi-step
+ // response (e.g. spawning subagents, chained tool calls) is not mistaken for a
+ // stuck process. The watchdog should only fire if Claude goes completely silent,
+ // not merely because the total turn duration exceeds the threshold.
+ *pending_since.lock() = Some(Instant::now());
+
let mut state = CharacterState::Typing;
let mut tool_name = None;
@@ -2025,6 +2041,21 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
"Running command...".to_string()
}
}
+ "CronCreate" => {
+ if let Some(prompt) = input.get("prompt").and_then(|v| v.as_str()) {
+ format!("Scheduling: {}", prompt)
+ } else {
+ "Scheduling recurring task...".to_string()
+ }
+ }
+ "CronDelete" => {
+ if let Some(id) = input.get("id").and_then(|v| v.as_str()) {
+ format!("Removing scheduled task: {}", id)
+ } else {
+ "Removing scheduled task...".to_string()
+ }
+ }
+ "CronList" => "Listing scheduled tasks...".to_string(),
_ => format!("Using tool: {}", name),
}
}
@@ -2191,6 +2222,41 @@ mod tests {
assert_eq!(desc, "Using tool: CustomTool");
}
+ #[test]
+ fn test_format_tool_description_cron_create() {
+ let input = serde_json::json!({"prompt": "run tests", "schedule": "*/5 * * * *"});
+ let desc = format_tool_description("CronCreate", &input);
+ assert_eq!(desc, "Scheduling: run tests");
+ }
+
+ #[test]
+ fn test_format_tool_description_cron_create_no_prompt() {
+ let input = serde_json::json!({});
+ let desc = format_tool_description("CronCreate", &input);
+ assert_eq!(desc, "Scheduling recurring task...");
+ }
+
+ #[test]
+ fn test_format_tool_description_cron_delete() {
+ let input = serde_json::json!({"id": "cron-abc123"});
+ let desc = format_tool_description("CronDelete", &input);
+ assert_eq!(desc, "Removing scheduled task: cron-abc123");
+ }
+
+ #[test]
+ fn test_format_tool_description_cron_delete_no_id() {
+ let input = serde_json::json!({});
+ let desc = format_tool_description("CronDelete", &input);
+ assert_eq!(desc, "Removing scheduled task...");
+ }
+
+ #[test]
+ fn test_format_tool_description_cron_list() {
+ let input = serde_json::json!({});
+ let desc = format_tool_description("CronList", &input);
+ assert_eq!(desc, "Listing scheduled tasks...");
+ }
+
#[test]
fn test_format_tool_description_memory_read() {
let input =
@@ -2655,4 +2721,47 @@ mod tests {
let exactly_at = Duration::from_secs(300);
assert!(exactly_at >= STUCK_TIMEOUT);
}
+
+ #[test]
+ fn test_pending_since_reset_on_assistant_message_simulates_long_response() {
+ // Regression test: an Assistant message arriving during a long multi-step response
+ // (e.g. subagents, chained tool calls) must reset pending_since to Instant::now()
+ // so the watchdog timer measures silence since the *last Claude activity*, not the
+ // total wall-clock time since the user's message was sent.
+ let pending_since: Arc>> = Arc::new(Mutex::new(None));
+
+ // User sends a message — watchdog timer starts
+ *pending_since.lock() = Some(Instant::now());
+ let original_instant = (*pending_since.lock()).unwrap();
+
+ // Simulate some time passing before Claude first responds (not enough to sleep in tests,
+ // but we verify the reset logic by recording the original instant and confirming it
+ // is replaced after an Assistant message arrives).
+ // In production this represents minutes of subagent work.
+
+ // Assistant message arrives — timer must be reset, not cleared
+ *pending_since.lock() = Some(Instant::now());
+
+ let after_reset = (*pending_since.lock()).unwrap();
+
+ // Still Some (watchdog still active until Result arrives)
+ assert!(pending_since.lock().is_some(), "pending_since must remain Some after an Assistant message");
+
+ // The reset instant must be >= the original (monotonic clock)
+ assert!(
+ after_reset >= original_instant,
+ "reset instant should be at least as recent as the original"
+ );
+
+ // Elapsed since reset is tiny — watchdog would NOT fire
+ let elapsed_since_reset = after_reset.elapsed();
+ assert!(
+ elapsed_since_reset < Duration::from_secs(1),
+ "elapsed since reset should be under 1 second in tests"
+ );
+
+ // Final Result clears it entirely
+ *pending_since.lock() = None;
+ assert!(pending_since.lock().is_none(), "pending_since cleared on Result");
+ }
}
diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte
index 4623a0a..969b4c2 100644
--- a/src/lib/components/ConfigSidebar.svelte
+++ b/src/lib/components/ConfigSidebar.svelte
@@ -58,6 +58,7 @@
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
+ disable_cron: false,
max_output_tokens: null,
trusted_workspaces: [],
background_image_path: null,
@@ -549,6 +550,22 @@
+
+
+
+
+ Sets CLAUDE_CODE_DISABLE_CRON=1 to prevent Claude from
+ scheduling recurring tasks
+
+
+