generated from nhcarrigan/template
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:
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user