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
This commit is contained in:
2026-03-10 11:08:58 -07:00
committed by Naomi Carrigan
parent 50fa4084ca
commit 292bf50f50
3 changed files with 136 additions and 0 deletions
+10
View File
@@ -34,6 +34,9 @@ pub struct ClaudeStartOptions {
#[serde(default)]
pub max_output_tokens: Option<u64>,
#[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]
+109
View File
@@ -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<Mutex<Option<Instant>>> = 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");
}
}
+17
View File
@@ -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 @@
</p>
</div>
<!-- Disable Cron Scheduling -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.disable_cron}
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
/>
<span class="text-sm text-[var(--text-primary)]">Disable cron scheduling</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Sets <code class="font-mono">CLAUDE_CODE_DISABLE_CRON=1</code> to prevent Claude from
scheduling recurring tasks
</p>
</div>
<!-- Max Output Tokens -->
<div class="mb-4">
<label class="block text-sm text-[var(--text-primary)] mb-1" for="max-output-tokens">