Merge branch 'worktree-feature-scheduled-tasks' into docs/ui-clone-requirements

Resolve PLAN.md conflict by keeping both plans: Provider management
system and Scheduled Tasks enhancement.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-07 01:48:03 +08:00
commit da2be1f81b
22 changed files with 1756 additions and 9 deletions

View File

@ -0,0 +1,19 @@
{
"tasks": [
{
"id": "b4851079",
"cron": "0 9 * * *",
"prompt": "test prompt",
"createdAt": 1775497208158,
"recurring": true,
"name": "test-name",
"description": "test description",
"folder": "/test/folder",
"model": "claude-opus-4-6",
"permissionMode": "ask",
"worktree": false,
"frequency": "daily",
"scheduledTime": "09:00"
}
]
}

336
PLAN.md
View File

@ -240,3 +240,339 @@ Body: { baseUrl, apiKey, modelId }
- 多 API 格式支持OpenAI 兼容等V2 考虑)
- Provider 导入/导出
- 自动故障转移failover
---
---
# Scheduled Tasks Enhancement Plan
## Overview
改进定时任务功能:支持编辑、新增工作目录选择、复用 sessions 组件。
参考官方 Claude Code 桌面端 APP 的 "New scheduled task" 对话框实现。
---
## Phase 1: Data Model Extension
### 1.1 扩展 CronTask 类型 (`src/utils/cronTasks.ts`)
在现有 `CronTask` 类型中新增以下字段:
```typescript
type CronTask = {
// ... existing fields ...
/** Human-readable task name (e.g. "daily-code-review") */
name?: string
/** Task description (e.g. "Review yesterday's commits") */
description?: string
/** Working directory for the task execution */
folder?: string
/** Model to use (e.g. "claude-opus-4-6", "claude-sonnet-4-6") */
model?: string
/** Permission mode: "ask" | "auto-accept" | "plan" | "bypass" */
permissionMode?: string
/** Whether to use git worktree for execution */
worktree?: boolean
/** Schedule frequency for UI display: "manual" | "hourly" | "daily" | "weekdays" | "weekly" */
frequency?: string
/** Time string for scheduled execution (e.g. "09:00") - UI helper for daily/weekdays/weekly */
scheduledTime?: string
}
```
**向后兼容**:所有新字段均为 optional旧数据无需迁移。
### 1.2 更新存储 read/write (`src/utils/cronTasks.ts`)
- `readCronTasks()`: 在读取时保留新字段name, description, folder, model, permissionMode, worktree, frequency, scheduledTime
- `writeCronTasks()`: 在写入时包含新字段(仍然 strip `durable``agentId`
- `addCronTask()`: 扩展函数签名接收新字段
### 1.3 新增 `updateCronTask()` 函数 (`src/utils/cronTasks.ts`)
```typescript
export async function updateCronTask(
id: string,
updates: Partial<Omit<CronTask, 'id' | 'createdAt'>>,
dir?: string,
): Promise<boolean>
```
- 查找并更新内存中session store或磁盘上的任务
- 如果 cron 表达式变化,重新验证
- 返回是否找到并更新成功
---
## Phase 2: CronUpdateTool (`src/tools/ScheduleCronTool/CronUpdateTool.ts`)
### 2.1 创建新工具文件
参照 `CronCreateTool.ts``CronDeleteTool.ts` 的模式:
```typescript
const inputSchema = z.strictObject({
id: z.string().describe('Job ID returned by CronCreate.'),
cron: z.string().optional().describe('New cron expression'),
prompt: z.string().optional().describe('New prompt'),
name: z.string().optional().describe('New name'),
description: z.string().optional().describe('New description'),
folder: z.string().optional().describe('New working directory'),
model: z.string().optional().describe('New model'),
permissionMode: z.string().optional().describe('New permission mode'),
worktree: z.boolean().optional().describe('New worktree setting'),
recurring: z.boolean().optional().describe('New recurring setting'),
frequency: z.string().optional().describe('New frequency'),
scheduledTime: z.string().optional().describe('New time'),
})
```
### 2.2 更新 prompt.ts
- 新增 `CRON_UPDATE_TOOL_NAME = 'CronUpdate'`
- 添加 description 和 prompt 构建函数
### 2.3 更新 UI.tsx
- 新增 `renderUpdateToolUseMessage()``renderUpdateResultMessage()`
---
## Phase 3: UI Components (复用 sessions 组件)
**核心原则**:所有可复用的组件直接 import 使用不复制代码。sessions 那边改了,这边自动生效。
### 3.1 频率到 Cron 表达式映射工具 (`src/utils/cronFrequency.ts`)
新建工具函数,在 UI 友好的频率设置和 cron 表达式之间互转:
```typescript
// Frequency → Cron
function frequencyToCron(frequency: string, time?: string): string
// "daily" + "09:00" → "0 9 * * *"
// "hourly" → "0 * * * *"
// "weekdays" + "09:00" → "0 9 * * 1-5"
// "weekly" + "09:00" → "0 9 * * 1"
// Cron → Frequency (best effort)
function cronToFrequency(cron: string): { frequency: string; time?: string }
```
### 3.2 定时任务向导 (`src/components/scheduled-tasks/ScheduledTaskWizard.tsx`)
使用现有 **Wizard 框架** (`src/components/wizard/`),创建多步骤向导:
```typescript
type ScheduledTaskWizardData = {
name: string
description: string
prompt: string
model?: string
permissionMode?: string
folder?: string
worktree?: boolean
frequency: string // "manual" | "hourly" | "daily" | "weekdays" | "weekly"
scheduledTime?: string // "09:00"
cron?: string // 最终生成的 cron 表达式
}
// 支持 create 和 edit 两种模式
type Props = {
mode: 'create' | 'edit'
initialData?: Partial<ScheduledTaskWizardData> // edit 模式下预填充
taskId?: string // edit 模式下的任务 ID
onComplete: (data: ScheduledTaskWizardData) => void
onCancel: () => void
}
```
**向导步骤**(每步复用 sessions 组件):
| Step | 组件 | 复用来源 |
|------|------|---------|
| 1. NameStep | `TextInput` | `src/components/TextInput.tsx` |
| 2. DescriptionStep | `TextInput` + external editor | `src/components/agents/new-agent-creation/wizard-steps/DescriptionStep.tsx` 的模式 |
| 3. PromptStep | `TextInput` + external editor | `src/components/agents/new-agent-creation/wizard-steps/PromptStep.tsx` 的模式 |
| 4. ModelStep | `ModelSelector` | **直接复用** `src/components/agents/ModelSelector.tsx` |
| 5. PermissionStep | `Select` | **直接复用** `src/components/CustomSelect/select.tsx` |
| 6. FolderStep | `Select` / `FuzzyPicker` | **复用** `src/components/CustomSelect/select.tsx` + `sessionStorage.ts` 的项目发现 |
| 7. ScheduleStep | `Select` + `TextInput` | **直接复用** `src/components/CustomSelect/select.tsx` |
| 8. ConfirmStep | Summary display | 使用 `Dialog` + `Text` 显示摘要 |
### 3.3 各步骤详细设计
#### Step 1: NameStep
```tsx
// 直接使用 TextInput参照 DescriptionStep 模式
function NameStep(): ReactNode {
const { goNext, goBack, updateWizardData, wizardData } = useWizard<ScheduledTaskWizardData>()
// TextInput with validation: name is required
}
```
#### Step 4: ModelStep复用 ModelSelector
```tsx
import { ModelSelector } from '../../agents/ModelSelector.js'
function ModelStep(): ReactNode {
const { goNext, updateWizardData, wizardData } = useWizard<ScheduledTaskWizardData>()
return (
<WizardDialogLayout subtitle="Select Model">
<ModelSelector
initialModel={wizardData.model}
onComplete={(model) => {
updateWizardData({ model })
goNext()
}}
onCancel={goBack}
/>
</WizardDialogLayout>
)
}
```
#### Step 5: PermissionStep复用 Select
```tsx
import { Select } from '../../CustomSelect/select.js'
const permissionOptions = [
{ label: 'Ask permissions', value: 'ask', description: 'Always ask before making changes' },
{ label: 'Auto accept edits', value: 'auto-accept', description: 'Automatically accept all file edits' },
{ label: 'Plan mode', value: 'plan', description: 'Create a plan before making changes' },
{ label: 'Bypass permissions', value: 'bypass', description: 'Accepts all permissions', disabled: false },
]
```
#### Step 6: FolderStep复用 sessionStorage 项目发现)
```tsx
import { loadAllProjectsMessageLogs } from '../../utils/sessionStorage.js'
// 或者直接读取 GlobalConfig.projects 获取最近项目列表
function FolderStep(): ReactNode {
// 1. 从 GlobalConfig.projects 获取已知项目路径
// 2. 使用 Select 展示 "Recent" 项目列表
// 3. 最后一项 "Choose a different folder" 允许手动输入路径
}
```
#### Step 7: ScheduleStep频率 + 时间)
```tsx
const frequencyOptions = [
{ label: 'Manual', value: 'manual' },
{ label: 'Hourly', value: 'hourly' },
{ label: 'Daily', value: 'daily' },
{ label: 'Weekdays', value: 'weekdays' },
{ label: 'Weekly', value: 'weekly' },
]
function ScheduleStep(): ReactNode {
// 1. 选择频率 (Select 组件)
// 2. 如果是 daily/weekdays/weekly显示时间输入 (TextInput格式 HH:MM)
// 3. 使用 frequencyToCron() 转换为 cron 表达式
}
```
#### Step 8: ConfirmStep
```tsx
function ConfirmStep(): ReactNode {
// 显示所有配置的摘要
// Enter 确认创建/更新
// Esc 返回上一步
}
```
---
## Phase 4: Integration
### 4.1 新增 /schedule-local Skill (`src/skills/bundled/scheduleLocal.ts`)
或修改现有 `/schedule` skill添加本地定时任务的向导流程
```typescript
// 用户输入 /schedule 时:
// 1. 如果没参数 → 显示 ScheduledTaskWizard (create 模式)
// 2. 如果参数是 task ID → 显示 ScheduledTaskWizard (edit 模式,预填充)
// 3. 如果参数是 "list" → 调用 CronList
```
### 4.2 注册 CronUpdateTool
在工具注册表中添加 CronUpdateTool
- 更新 `src/tools/ScheduleCronTool/` 导出
- 确保工具在 `isKairosCronEnabled()` 条件下启用
### 4.3 更新 CronListTool 输出
在列表输出中包含新字段name, description, folder, model 等),方便用户识别任务。
---
## Phase 5: Testing
### 5.1 单元测试
- `cronTasks.ts`: 测试 updateCronTask() 的各种场景(内存任务、磁盘任务、不存在的 ID
- `cronFrequency.ts`: 测试频率到 cron 的双向转换
- `CronUpdateTool.ts`: 测试验证逻辑(无效 ID、权限检查
### 5.2 集成测试
- 验证 wizard 创建的任务能被 scheduler 正确执行
- 验证编辑后的任务能正确更新 nextFireAt
---
## File Changes Summary
| File | Change Type | Description |
|------|------------|-------------|
| `src/utils/cronTasks.ts` | **Modified** | 扩展 CronTask 类型,新增 updateCronTask(),更新 read/write/add |
| `src/utils/cronFrequency.ts` | **New** | 频率 ↔ Cron 表达式转换工具 |
| `src/tools/ScheduleCronTool/CronUpdateTool.ts` | **New** | 编辑定时任务工具 |
| `src/tools/ScheduleCronTool/prompt.ts` | **Modified** | 新增 CronUpdate 的 name/description/prompt |
| `src/tools/ScheduleCronTool/UI.tsx` | **Modified** | 新增 update 的 render 函数 |
| `src/components/scheduled-tasks/ScheduledTaskWizard.tsx` | **New** | 定时任务向导(复用 sessions 组件) |
| `src/components/scheduled-tasks/steps/NameStep.tsx` | **New** | 名称输入步骤 |
| `src/components/scheduled-tasks/steps/DescriptionStep.tsx` | **New** | 描述输入步骤 |
| `src/components/scheduled-tasks/steps/PromptStep.tsx` | **New** | Prompt 输入步骤 |
| `src/components/scheduled-tasks/steps/ModelStep.tsx` | **New** | 模型选择步骤(复用 ModelSelector |
| `src/components/scheduled-tasks/steps/PermissionStep.tsx` | **New** | 权限模式步骤(复用 Select |
| `src/components/scheduled-tasks/steps/FolderStep.tsx` | **New** | 工作目录步骤(复用 Select + 项目发现) |
| `src/components/scheduled-tasks/steps/ScheduleStep.tsx` | **New** | 频率+时间步骤(复用 Select |
| `src/components/scheduled-tasks/steps/ConfirmStep.tsx` | **New** | 确认步骤 |
| `src/skills/bundled/scheduleLocal.ts` | **New or Modified** | /schedule skill 集成向导 |
| `src/hooks/useScheduledTasks.ts` | **Modified** | 支持 folder/model/permissionMode/worktree 在任务执行时生效 |
## Key Reuse Points (复用列表)
确保以下组件是直接 import 复用,**不是复制代码**
1. **`WizardProvider`** - `src/components/wizard/WizardProvider.tsx`
2. **`WizardDialogLayout`** - `src/components/wizard/WizardDialogLayout.tsx`
3. **`useWizard`** - `src/components/wizard/useWizard.ts`
4. **`WizardNavigationFooter`** - `src/components/wizard/WizardNavigationFooter.tsx`
5. **`ModelSelector`** - `src/components/agents/ModelSelector.tsx`
6. **`Select`** - `src/components/CustomSelect/select.tsx`
7. **`TextInput`** - `src/components/TextInput.tsx`
8. **`Dialog`** - `src/components/design-system/Dialog.tsx`
9. **`FuzzyPicker`** - `src/components/design-system/FuzzyPicker.tsx`(如 FolderStep 需要搜索)
10. **`useKeybinding`** - `src/hooks/useKeybinding.ts`
11. **Session Project Discovery** - `src/utils/sessionStorage.ts` (loadAllProjectsMessageLogs)
12. **`GlobalConfig.projects`** - `src/utils/config.ts`(最近项目列表)
## Implementation Order
1. Phase 1 (Data Model) → 2. Phase 2 (CronUpdateTool) → 3. Phase 3 (UI) → 4. Phase 4 (Integration) → 5. Phase 5 (Testing)
每个 Phase 完成后做一次代码审查。

View File

@ -1289,6 +1289,22 @@ export type SessionCronTask = {
* instead of the main REPL command queue. Session-only never written to disk.
*/
agentId?: string
/** Human-readable task name. */
name?: string
/** Task description. */
description?: string
/** Working directory for the task execution. */
folder?: string
/** Model to use. */
model?: string
/** Permission mode. */
permissionMode?: string
/** Whether to use git worktree for execution. */
worktree?: boolean
/** Schedule frequency for UI display. */
frequency?: string
/** Time string for scheduled execution. */
scheduledTime?: string
}
export function getSessionCronTasks(): SessionCronTask[] {

View File

@ -0,0 +1,49 @@
import React, { type ReactNode } from 'react'
import { WizardProvider } from '../wizard/index.js'
import type { ScheduledTaskWizardData } from './types.js'
import { NameStep } from './steps/NameStep.js'
import { TaskDescriptionStep } from './steps/TaskDescriptionStep.js'
import { TaskPromptStep } from './steps/TaskPromptStep.js'
import { TaskModelStep } from './steps/TaskModelStep.js'
import { PermissionStep } from './steps/PermissionStep.js'
import { FolderStep } from './steps/FolderStep.js'
import { ScheduleStep } from './steps/ScheduleStep.js'
import { TaskConfirmStep } from './steps/TaskConfirmStep.js'
type Props = {
mode: 'create' | 'edit'
initialData?: Partial<ScheduledTaskWizardData>
onComplete: (data: ScheduledTaskWizardData) => void
onCancel: () => void
}
export function ScheduledTaskWizard({
mode,
initialData = {},
onComplete,
onCancel,
}: Props): ReactNode {
const steps = [
NameStep,
TaskDescriptionStep,
TaskPromptStep,
TaskModelStep,
PermissionStep,
FolderStep,
ScheduleStep,
TaskConfirmStep,
]
const title = mode === 'create' ? 'New scheduled task' : 'Edit scheduled task'
return (
<WizardProvider
steps={steps}
initialData={initialData as ScheduledTaskWizardData}
onComplete={onComplete}
onCancel={onCancel}
title={title}
showStepCounter={true}
/>
)
}

View File

@ -0,0 +1,124 @@
import React, { type ReactNode, useMemo, useState } from 'react'
import { Box, Text } from '../../../ink.js'
import { getProjectRoot } from '../../../bootstrap/state.js'
import { useKeybinding } from '../../../hooks/useKeybinding.js'
import TextInput from '../../TextInput.js'
import { Select } from '../../CustomSelect/select.js'
import { WizardDialogLayout } from '../../wizard/index.js'
import { useWizard } from '../../wizard/useWizard.js'
import type { ScheduledTaskWizardData } from '../types.js'
/** Reject paths that escape the filesystem root or contain dangerous patterns. */
function isSafePath(path: string): boolean {
const normalized = path.trim()
// Reject empty paths, absolute paths outside root, or traversal attempts.
if (!normalized) return false
if (normalized.startsWith('/')) return true // absolute paths are allowed
if (normalized.startsWith('~')) return true // home directory is allowed
// Disallow traversal patterns.
if (normalized.includes('..')) return false
return true
}
export function FolderStep(): ReactNode {
const { goNext, goBack, updateWizardData, wizardData } =
useWizard<ScheduledTaskWizardData>()
const [customPath, setCustomPath] = useState(false)
const [pathValue, setPathValue] = useState(wizardData.folder ?? '')
const [pathError, setPathError] = useState<string | null>(null)
const currentProject = getProjectRoot()
useKeybinding('confirm:no', () => {
if (customPath) {
setCustomPath(false)
} else {
goBack()
}
}, { context: 'Settings' })
const folderOptions = useMemo(() => {
const options: { label: string; value: string; description?: string }[] = []
// Current project is always first
options.push({
label: currentProject.split('/').pop() ?? currentProject,
value: currentProject,
description: currentProject,
})
return options
}, [currentProject])
// Custom path input mode — uses TextInput instead of Select input type
if (customPath) {
return (
<WizardDialogLayout subtitle="Working directory">
<Box flexDirection="column">
<Box marginBottom={1}>
<Text dimColor>Enter the full path to the working directory:</Text>
</Box>
<TextInput
value={pathValue}
onChange={(v) => {
setPathValue(v)
setPathError(null)
}}
onSubmit={() => {
const trimmed = pathValue.trim()
if (!trimmed) {
setPathError('Path cannot be empty')
return
}
if (!isSafePath(trimmed)) {
setPathError('Invalid path')
return
}
setPathError(null)
updateWizardData({ folder: trimmed })
goNext()
}}
placeholder="/path/to/project"
/>
{pathError && (
<Box marginTop={1}>
<Text color="red">{pathError}</Text>
</Box>
)}
</Box>
</WizardDialogLayout>
)
}
return (
<WizardDialogLayout subtitle="Working directory">
<Box flexDirection="column">
<Box marginBottom={1}>
<Text dimColor>
Select the folder where this task will run.
</Text>
</Box>
<Select
options={[
...folderOptions,
{
label: '+ Choose a different folder',
value: '__custom__',
description: 'Enter a custom path',
},
]}
defaultValue={wizardData.folder ?? currentProject}
onChange={(value) => {
if (value === '__custom__') {
setCustomPath(true)
return
}
updateWizardData({ folder: value })
goNext()
}}
onCancel={goBack}
/>
</Box>
</WizardDialogLayout>
)
}

View File

@ -0,0 +1,51 @@
import React, { type ReactNode, useState } from 'react'
import { Box, Text } from '../../../ink.js'
import { useKeybinding } from '../../../hooks/useKeybinding.js'
import TextInput from '../../TextInput.js'
import { WizardDialogLayout } from '../../wizard/index.js'
import { useWizard } from '../../wizard/useWizard.js'
import type { ScheduledTaskWizardData } from '../types.js'
export function NameStep(): ReactNode {
const { goNext, goBack, updateWizardData, wizardData } =
useWizard<ScheduledTaskWizardData>()
const [value, setValue] = useState(wizardData.name ?? '')
const [error, setError] = useState<string | null>(null)
useKeybinding('confirm:no', goBack, { context: 'Settings' })
const handleSubmit = () => {
const trimmed = value.trim()
if (!trimmed) {
setError('Name is required')
return
}
setError(null)
updateWizardData({ name: trimmed })
goNext()
}
return (
<WizardDialogLayout subtitle="Task name">
<Box flexDirection="column">
<Box marginBottom={1}>
<Text dimColor>
Give your scheduled task a short, descriptive name (e.g.
&quot;daily-code-review&quot;).
</Text>
</Box>
<TextInput
value={value}
onChange={setValue}
onSubmit={handleSubmit}
placeholder="e.g. daily-code-review"
/>
{error && (
<Box marginTop={1}>
<Text color="red">{error}</Text>
</Box>
)}
</Box>
</WizardDialogLayout>
)
}

View File

@ -0,0 +1,55 @@
import React, { type ReactNode } from 'react'
import { Box, Text } from '../../../ink.js'
import { Select } from '../../CustomSelect/select.js'
import { WizardDialogLayout } from '../../wizard/index.js'
import { useWizard } from '../../wizard/useWizard.js'
import type { ScheduledTaskWizardData } from '../types.js'
const PERMISSION_OPTIONS = [
{
label: 'Ask permissions',
value: 'ask',
description: 'Always ask before making changes',
},
{
label: 'Auto accept edits',
value: 'auto-accept',
description: 'Automatically accept all file edits',
},
{
label: 'Plan mode',
value: 'plan',
description: 'Create a plan before making changes',
},
{
label: 'Bypass permissions',
value: 'bypass',
description: 'Accepts all permissions',
},
]
export function PermissionStep(): ReactNode {
const { goNext, goBack, updateWizardData, wizardData } =
useWizard<ScheduledTaskWizardData>()
return (
<WizardDialogLayout subtitle="Permission mode">
<Box flexDirection="column">
<Box marginBottom={1}>
<Text dimColor>
Choose the permission mode for this scheduled task.
</Text>
</Box>
<Select
options={PERMISSION_OPTIONS}
defaultValue={wizardData.permissionMode ?? 'ask'}
onChange={(value) => {
updateWizardData({ permissionMode: value })
goNext()
}}
onCancel={goBack}
/>
</Box>
</WizardDialogLayout>
)
}

View File

@ -0,0 +1,101 @@
import React, { type ReactNode, useState } from 'react'
import { Box, Text } from '../../../ink.js'
import { useKeybinding } from '../../../hooks/useKeybinding.js'
import { Select } from '../../CustomSelect/select.js'
import TextInput from '../../TextInput.js'
import { WizardDialogLayout } from '../../wizard/index.js'
import { useWizard } from '../../wizard/useWizard.js'
import {
FREQUENCY_OPTIONS,
frequencyToCron,
type Frequency,
} from '../../../utils/cronFrequency.js'
import type { ScheduledTaskWizardData } from '../types.js'
export function ScheduleStep(): ReactNode {
const { goNext, goBack, updateWizardData, wizardData } =
useWizard<ScheduledTaskWizardData>()
const [frequency, setFrequency] = useState<Frequency>(
(wizardData.frequency as Frequency) ?? 'daily',
)
const [showTimePicker, setShowTimePicker] = useState(false)
const [time, setTime] = useState(wizardData.scheduledTime ?? '09:00')
useKeybinding('confirm:no', goBack, { context: 'Settings' })
const needsTime = frequency === 'daily' || frequency === 'weekdays' || frequency === 'weekly'
const handleFrequencySelect = (value: string) => {
const freq = value as Frequency
setFrequency(freq)
if (freq === 'manual' || freq === 'hourly') {
// No time needed
const cron = frequencyToCron(freq)
updateWizardData({
frequency: freq,
scheduledTime: undefined,
cron: cron || undefined,
})
goNext()
} else {
// Show time picker for daily/weekdays/weekly
setShowTimePicker(true)
}
}
const handleTimeSubmit = () => {
// Validate time format HH:MM
if (!/^\d{1,2}:\d{2}$/.test(time)) return
const cron = frequencyToCron(frequency, time)
updateWizardData({
frequency,
scheduledTime: time,
cron: cron || undefined,
})
goNext()
}
if (showTimePicker && needsTime) {
return (
<WizardDialogLayout subtitle="Schedule time">
<Box flexDirection="column">
<Box marginBottom={1}>
<Text dimColor>
Enter the time for this task (24-hour format, e.g. 09:00):
</Text>
</Box>
<TextInput
value={time}
onChange={setTime}
onSubmit={handleTimeSubmit}
placeholder="09:00"
/>
<Box marginTop={1}>
<Text dimColor>
Scheduled tasks use a randomized delay of several minutes for
server performance.
</Text>
</Box>
</Box>
</WizardDialogLayout>
)
}
return (
<WizardDialogLayout subtitle="Frequency">
<Box flexDirection="column">
<Box marginBottom={1}>
<Text dimColor>How often should this task run?</Text>
</Box>
<Select
options={FREQUENCY_OPTIONS}
defaultValue={frequency}
onChange={handleFrequencySelect}
onCancel={goBack}
/>
</Box>
</WizardDialogLayout>
)
}

View File

@ -0,0 +1,69 @@
import React, { type ReactNode } from 'react'
import { Box, Text } from '../../../ink.js'
import { useKeybinding } from '../../../hooks/useKeybinding.js'
import { cronToHuman } from '../../../utils/cron.js'
import { WizardDialogLayout } from '../../wizard/index.js'
import { useWizard } from '../../wizard/useWizard.js'
import type { ScheduledTaskWizardData } from '../types.js'
export function TaskConfirmStep(): ReactNode {
const { goNext, goBack, wizardData } =
useWizard<ScheduledTaskWizardData>()
useKeybinding('confirm:no', goBack, { context: 'Settings' })
const schedule = wizardData.cron
? cronToHuman(wizardData.cron)
: wizardData.frequency === 'manual'
? 'Manual (on demand)'
: 'Not set'
return (
<WizardDialogLayout subtitle="Review & confirm">
<Box flexDirection="column" gap={1}>
<Box>
<Text bold>Name: </Text>
<Text>{wizardData.name ?? '—'}</Text>
</Box>
<Box>
<Text bold>Description: </Text>
<Text>{wizardData.description ?? '—'}</Text>
</Box>
<Box>
<Text bold>Prompt: </Text>
<Text>
{wizardData.prompt
? wizardData.prompt.length > 60
? wizardData.prompt.slice(0, 57) + '...'
: wizardData.prompt
: '—'}
</Text>
</Box>
<Box>
<Text bold>Model: </Text>
<Text>{wizardData.model ?? 'default'}</Text>
</Box>
<Box>
<Text bold>Permissions: </Text>
<Text>{wizardData.permissionMode ?? 'ask'}</Text>
</Box>
<Box>
<Text bold>Folder: </Text>
<Text>{wizardData.folder ?? 'current project'}</Text>
</Box>
<Box>
<Text bold>Worktree: </Text>
<Text>{wizardData.worktree ? 'yes' : 'no'}</Text>
</Box>
<Box>
<Text bold>Schedule: </Text>
<Text>{schedule}</Text>
</Box>
<Box marginTop={1}>
<Text dimColor>Press Enter to confirm, Esc to go back.</Text>
</Box>
</Box>
</WizardDialogLayout>
)
}

View File

@ -0,0 +1,50 @@
import React, { type ReactNode, useState } from 'react'
import { Box, Text } from '../../../ink.js'
import { useKeybinding } from '../../../hooks/useKeybinding.js'
import TextInput from '../../TextInput.js'
import { WizardDialogLayout } from '../../wizard/index.js'
import { useWizard } from '../../wizard/useWizard.js'
import type { ScheduledTaskWizardData } from '../types.js'
export function TaskDescriptionStep(): ReactNode {
const { goNext, goBack, updateWizardData, wizardData } =
useWizard<ScheduledTaskWizardData>()
const [value, setValue] = useState(wizardData.description ?? '')
const [error, setError] = useState<string | null>(null)
useKeybinding('confirm:no', goBack, { context: 'Settings' })
const handleSubmit = () => {
const trimmed = value.trim()
if (!trimmed) {
setError('Description is required')
return
}
setError(null)
updateWizardData({ description: trimmed })
goNext()
}
return (
<WizardDialogLayout subtitle="Description">
<Box flexDirection="column">
<Box marginBottom={1}>
<Text dimColor>
Briefly describe what this scheduled task does.
</Text>
</Box>
<TextInput
value={value}
onChange={setValue}
onSubmit={handleSubmit}
placeholder="e.g. Review yesterday's commits and flag anything concerning"
/>
{error && (
<Box marginTop={1}>
<Text color="red">{error}</Text>
</Box>
)}
</Box>
</WizardDialogLayout>
)
}

View File

@ -0,0 +1,23 @@
import React, { type ReactNode } from 'react'
import { WizardDialogLayout } from '../../wizard/index.js'
import { useWizard } from '../../wizard/useWizard.js'
import { ModelSelector } from '../../agents/ModelSelector.js'
import type { ScheduledTaskWizardData } from '../types.js'
export function TaskModelStep(): ReactNode {
const { goNext, goBack, updateWizardData, wizardData } =
useWizard<ScheduledTaskWizardData>()
return (
<WizardDialogLayout subtitle="Model">
<ModelSelector
initialModel={wizardData.model}
onComplete={(model) => {
updateWizardData({ model })
goNext()
}}
onCancel={goBack}
/>
</WizardDialogLayout>
)
}

View File

@ -0,0 +1,50 @@
import React, { type ReactNode, useState } from 'react'
import { Box, Text } from '../../../ink.js'
import { useKeybinding } from '../../../hooks/useKeybinding.js'
import TextInput from '../../TextInput.js'
import { WizardDialogLayout } from '../../wizard/index.js'
import { useWizard } from '../../wizard/useWizard.js'
import type { ScheduledTaskWizardData } from '../types.js'
export function TaskPromptStep(): ReactNode {
const { goNext, goBack, updateWizardData, wizardData } =
useWizard<ScheduledTaskWizardData>()
const [value, setValue] = useState(wizardData.prompt ?? '')
const [error, setError] = useState<string | null>(null)
useKeybinding('confirm:no', goBack, { context: 'Settings' })
const handleSubmit = () => {
const trimmed = value.trim()
if (!trimmed) {
setError('Prompt is required')
return
}
setError(null)
updateWizardData({ prompt: trimmed })
goNext()
}
return (
<WizardDialogLayout subtitle="Prompt">
<Box flexDirection="column">
<Box marginBottom={1}>
<Text dimColor>
Enter the prompt that will be sent to Claude when this task runs.
</Text>
</Box>
<TextInput
value={value}
onChange={setValue}
onSubmit={handleSubmit}
placeholder="e.g. Look at the commits from the last 24 hours..."
/>
{error && (
<Box marginTop={1}>
<Text color="red">{error}</Text>
</Box>
)}
</Box>
</WizardDialogLayout>
)
}

View File

@ -0,0 +1,12 @@
export type ScheduledTaskWizardData = {
name?: string
description?: string
prompt?: string
model?: string
permissionMode?: string
folder?: string
worktree?: boolean
frequency?: string
scheduledTime?: string
cron?: string
}

View File

@ -29,6 +29,7 @@ const SleepTool =
const cronTools = feature('AGENT_TRIGGERS')
? [
require('./tools/ScheduleCronTool/CronCreateTool.js').CronCreateTool,
require('./tools/ScheduleCronTool/CronUpdateTool.js').CronUpdateTool,
require('./tools/ScheduleCronTool/CronDeleteTool.js').CronDeleteTool,
require('./tools/ScheduleCronTool/CronListTool.js').CronListTool,
]

View File

@ -27,6 +27,14 @@ const outputSchema = lazySchema(() =>
prompt: z.string(),
recurring: z.boolean().optional(),
durable: z.boolean().optional(),
name: z.string().optional(),
description: z.string().optional(),
folder: z.string().optional(),
model: z.string().optional(),
permissionMode: z.string().optional(),
worktree: z.boolean().optional(),
frequency: z.string().optional(),
scheduledTime: z.string().optional(),
}),
),
}),
@ -74,6 +82,14 @@ export const CronListTool = buildTool({
prompt: t.prompt,
...(t.recurring ? { recurring: true } : {}),
...(t.durable === false ? { durable: false } : {}),
...(t.name ? { name: t.name } : {}),
...(t.description ? { description: t.description } : {}),
...(t.folder ? { folder: t.folder } : {}),
...(t.model ? { model: t.model } : {}),
...(t.permissionMode ? { permissionMode: t.permissionMode } : {}),
...(t.worktree !== undefined ? { worktree: t.worktree } : {}),
...(t.frequency ? { frequency: t.frequency } : {}),
...(t.scheduledTime ? { scheduledTime: t.scheduledTime } : {}),
}))
return { data: { jobs } }
},
@ -86,7 +102,7 @@ export const CronListTool = buildTool({
? output.jobs
.map(
j =>
`${j.id}${j.humanSchedule}${j.recurring ? ' (recurring)' : ' (one-shot)'}${j.durable === false ? ' [session-only]' : ''}: ${truncate(j.prompt, 80, true)}`,
`${j.id}${j.name ? ` [${j.name}]` : ''}${j.humanSchedule}${j.recurring ? ' (recurring)' : ' (one-shot)'}${j.durable === false ? ' [session-only]' : ''}${j.folder ? ` (${j.folder})` : ''}: ${truncate(j.prompt, 80, true)}`,
)
.join('\n')
: 'No scheduled jobs.',

View File

@ -0,0 +1,180 @@
import { z } from 'zod/v4'
import type { ValidationResult } from '../../Tool.js'
import { buildTool, type ToolDef } from '../../Tool.js'
import { cronToHuman, parseCronExpression } from '../../utils/cron.js'
import type { CronTask } from '../../utils/cronTasks.js'
import {
getCronFilePath,
listAllCronTasks,
nextCronRunMs,
updateCronTask,
} from '../../utils/cronTasks.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { semanticBoolean } from '../../utils/semanticBoolean.js'
import { getTeammateContext } from '../../utils/teammateContext.js'
import {
buildCronUpdatePrompt,
CRON_UPDATE_DESCRIPTION,
CRON_UPDATE_TOOL_NAME,
isDurableCronEnabled,
isKairosCronEnabled,
} from './prompt.js'
import { renderUpdateResultMessage, renderUpdateToolUseMessage } from './UI.js'
const inputSchema = lazySchema(() =>
z.strictObject({
id: z.string().describe('Job ID returned by CronCreate.'),
cron: z
.string()
.optional()
.describe('New 5-field cron expression in local time.'),
prompt: z.string().optional().describe('New prompt to enqueue at each fire time.'),
name: z.string().optional().describe('New task name.'),
description: z.string().optional().describe('New task description.'),
folder: z.string().optional().describe('New working directory path.'),
model: z.string().optional().describe('New model to use.'),
permissionMode: z
.string()
.optional()
.describe('New permission mode: "ask" | "auto-accept" | "plan" | "bypass".'),
worktree: semanticBoolean(z.boolean().optional()).describe(
'New worktree setting.',
),
recurring: semanticBoolean(z.boolean().optional()).describe(
'New recurring setting.',
),
frequency: z
.string()
.optional()
.describe('New frequency: "manual" | "hourly" | "daily" | "weekdays" | "weekly".'),
scheduledTime: z
.string()
.optional()
.describe('New time string (e.g. "09:00").'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
id: z.string(),
humanSchedule: z.string(),
updated: z.boolean(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type UpdateOutput = z.infer<OutputSchema>
export const CronUpdateTool = buildTool({
name: CRON_UPDATE_TOOL_NAME,
searchHint: 'update/edit a scheduled cron job',
maxResultSizeChars: 100_000,
shouldDefer: true,
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
isEnabled() {
return isKairosCronEnabled()
},
toAutoClassifierInput(input) {
return input.id
},
async description() {
return CRON_UPDATE_DESCRIPTION
},
async prompt() {
return buildCronUpdatePrompt(isDurableCronEnabled())
},
getPath() {
return getCronFilePath()
},
async validateInput(input): Promise<ValidationResult> {
const tasks = await listAllCronTasks()
const task = tasks.find(t => t.id === input.id)
if (!task) {
return {
result: false,
message: `No scheduled job with id '${input.id}'`,
errorCode: 1,
}
}
// Teammates may only update their own crons.
const ctx = getTeammateContext()
if (ctx && task.agentId !== ctx.agentId) {
return {
result: false,
message: `Cannot update cron job '${input.id}': owned by another agent`,
errorCode: 2,
}
}
// Validate new cron expression if provided.
if (input.cron !== undefined) {
if (!parseCronExpression(input.cron)) {
return {
result: false,
message: `Invalid cron expression '${input.cron}'. Expected 5 fields: M H DoM Mon DoW.`,
errorCode: 3,
}
}
if (nextCronRunMs(input.cron, Date.now()) === null) {
return {
result: false,
message: `Cron expression '${input.cron}' does not match any calendar date in the next year.`,
errorCode: 4,
}
}
}
return { result: true }
},
async call({ id, ...updates }) {
// Only include fields that were explicitly provided (not undefined).
// Use a whitelist of valid field names to discard typos.
const VALID_FIELDS: ReadonlySet<string> = new Set([
'cron', 'prompt', 'name', 'description', 'folder', 'model',
'permissionMode', 'worktree', 'recurring', 'frequency', 'scheduledTime',
])
const cleanUpdates: Partial<Omit<CronTask, 'id' | 'createdAt'>> = {}
for (const [k, v] of Object.entries(updates)) {
if (v !== undefined && VALID_FIELDS.has(k)) {
(cleanUpdates as Record<string, unknown>)[k] = v
}
}
// Read the current cron before updating, so we can display the
// effective schedule without a redundant disk read after the update.
let currentCron = ''
if (!updates.cron) {
const tasks = await listAllCronTasks()
const t = tasks.find(t => t.id === id)
currentCron = t?.cron ?? ''
}
const updated = await updateCronTask(id, cleanUpdates)
const effectiveCron = updates.cron ?? currentCron
const humanSchedule = effectiveCron
? cronToHuman(effectiveCron)
: 'unknown'
return {
data: {
id,
humanSchedule,
updated,
},
}
},
mapToolResultToToolResultBlockParam(output, toolUseID) {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: output.updated
? `Updated job ${output.id} (${output.humanSchedule}).`
: `Job ${output.id} not found — it may have been deleted.`,
}
},
renderToolUseMessage: renderUpdateToolUseMessage,
renderToolResultMessage: renderUpdateResultMessage,
} satisfies ToolDef<InputSchema, UpdateOutput>)

View File

@ -3,6 +3,7 @@ import { MessageResponse } from '../../components/MessageResponse.js';
import { Text } from '../../ink.js';
import { truncate } from '../../utils/format.js';
import type { CreateOutput } from './CronCreateTool.js';
import type { UpdateOutput } from './CronUpdateTool.js';
import type { DeleteOutput } from './CronDeleteTool.js';
import type { ListOutput } from './CronListTool.js';
@ -23,6 +24,27 @@ export function renderCreateResultMessage(output: CreateOutput): React.ReactNode
</MessageResponse>;
}
// --- CronUpdate -------------------------------------------------------------
export function renderUpdateToolUseMessage(input: Partial<{
id: string;
}>): React.ReactNode {
return input.id ?? '';
}
export function renderUpdateResultMessage(output: UpdateOutput): React.ReactNode {
if (!output.updated) {
return <MessageResponse>
<Text dimColor>Job {output.id} not found</Text>
</MessageResponse>;
}
return <MessageResponse>
<Text>
Updated <Text bold>{output.id}</Text>{' '}
<Text dimColor>({output.humanSchedule})</Text>
</Text>
</MessageResponse>;
}
// --- CronDelete -------------------------------------------------------------
export function renderDeleteToolUseMessage(input: Partial<{
@ -51,7 +73,7 @@ export function renderListResultMessage(output: ListOutput): React.ReactNode {
}
return <MessageResponse>
{output.jobs.map(j => <Text key={j.id}>
<Text bold>{j.id}</Text> <Text dimColor>{j.humanSchedule}</Text>
<Text bold>{j.id}</Text>{j.name ? <Text> [{j.name}]</Text> : null} <Text dimColor>{j.humanSchedule}</Text>{j.folder ? <Text dimColor> ({j.folder})</Text> : null}
</Text>)}
</MessageResponse>;
}

View File

@ -62,6 +62,7 @@ export function isDurableCronEnabled(): boolean {
}
export const CRON_CREATE_TOOL_NAME = 'CronCreate'
export const CRON_UPDATE_TOOL_NAME = 'CronUpdate'
export const CRON_DELETE_TOOL_NAME = 'CronDelete'
export const CRON_LIST_TOOL_NAME = 'CronList'
@ -120,6 +121,13 @@ Recurring tasks auto-expire after ${DEFAULT_MAX_AGE_DAYS} days — they fire one
Returns a job ID you can pass to ${CRON_DELETE_TOOL_NAME}.`
}
export const CRON_UPDATE_DESCRIPTION =
'Update a scheduled cron job by ID — change its cron expression, prompt, name, description, folder, model, permission mode, worktree, or recurring setting.'
export function buildCronUpdatePrompt(durableEnabled: boolean): string {
return `Update an existing cron job previously scheduled with ${CRON_CREATE_TOOL_NAME}. Pass the job ID and any fields you want to change — only the provided fields are updated, the rest stay the same.${durableEnabled ? ' Works for both durable and session-only jobs.' : ''}`
}
export const CRON_DELETE_DESCRIPTION = 'Cancel a scheduled cron job by ID'
export function buildCronDeletePrompt(durableEnabled: boolean): string {
return durableEnabled

View File

@ -0,0 +1,153 @@
import { describe, expect, test } from 'bun:test'
import {
frequencyToCron,
cronToFrequency,
FREQUENCY_OPTIONS,
} from '../cronFrequency.js'
describe('frequencyToCron', () => {
test('manual returns empty string', () => {
expect(frequencyToCron('manual')).toBe('')
expect(frequencyToCron('manual', '09:00')).toBe('')
})
test('hourly returns minute-only cron', () => {
expect(frequencyToCron('hourly')).toBe('0 * * * *')
expect(frequencyToCron('hourly', '15')).toBe('') // malformed: must be HH:MM
expect(frequencyToCron('hourly', '0 * * * *')).toBe('') // malformed time
expect(frequencyToCron('hourly', ':15')).toBe('') // malformed
expect(frequencyToCron('hourly', '00:15')).toBe('15 * * * *')
})
test('daily returns correct cron', () => {
expect(frequencyToCron('daily', '09:00')).toBe('0 9 * * *')
expect(frequencyToCron('daily', '14:30')).toBe('30 14 * * *')
})
test('weekdays returns cron with 1-5 dow', () => {
expect(frequencyToCron('weekdays', '09:00')).toBe('0 9 * * 1-5')
expect(frequencyToCron('weekdays', '08:30')).toBe('30 8 * * 1-5')
})
test('weekly returns cron with dow=1', () => {
expect(frequencyToCron('weekly', '09:00')).toBe('0 9 * * 1')
expect(frequencyToCron('weekly', '17:00')).toBe('0 17 * * 1')
})
test('rejects invalid time formats', () => {
expect(frequencyToCron('daily', '')).toBe('')
expect(frequencyToCron('daily', '25:00')).toBe('')
expect(frequencyToCron('daily', '09:60')).toBe('')
expect(frequencyToCron('daily', 'abc')).toBe('')
expect(frequencyToCron('daily', '9')).toBe('')
expect(frequencyToCron('daily', ':30')).toBe('')
})
test('defaults to 09:00 for missing time', () => {
expect(frequencyToCron('daily')).toBe('0 9 * * *')
expect(frequencyToCron('weekdays')).toBe('0 9 * * 1-5')
expect(frequencyToCron('weekly')).toBe('0 9 * * 1')
})
})
describe('cronToFrequency', () => {
test('empty cron returns manual', () => {
expect(cronToFrequency('')).toEqual({ frequency: 'manual' })
expect(cronToFrequency(' ')).toEqual({ frequency: 'manual' })
})
test('invalid cron returns manual', () => {
expect(cronToFrequency('not a cron')).toEqual({ frequency: 'manual' })
expect(cronToFrequency('*')).toEqual({ frequency: 'manual' })
expect(cronToFrequency('* *')).toEqual({ frequency: 'manual' })
})
test('hourly pattern detected', () => {
expect(cronToFrequency('7 * * * *')).toEqual({ frequency: 'hourly' })
expect(cronToFrequency('0 * * * *')).toEqual({ frequency: 'hourly' })
expect(cronToFrequency('30 * * * *')).toEqual({ frequency: 'hourly' })
})
test('daily pattern detected', () => {
expect(cronToFrequency('0 9 * * *')).toEqual({ frequency: 'daily', time: '09:00' })
expect(cronToFrequency('30 14 * * *')).toEqual({ frequency: 'daily', time: '14:30' })
})
test('weekdays pattern detected', () => {
expect(cronToFrequency('0 9 * * 1-5')).toEqual({ frequency: 'weekdays', time: '09:00' })
expect(cronToFrequency('30 8 * * 1-5')).toEqual({ frequency: 'weekdays', time: '08:30' })
})
test('weekly pattern detected', () => {
expect(cronToFrequency('0 9 * * 1')).toEqual({ frequency: 'weekly', time: '09:00' })
expect(cronToFrequency('0 17 * * 1')).toEqual({ frequency: 'weekly', time: '17:00' })
})
test('complex crons return manual', () => {
expect(cronToFrequency('0 9 1-15 * *')).toEqual({ frequency: 'manual' })
expect(cronToFrequency('0 9 * * 0,6')).toEqual({ frequency: 'manual' })
expect(cronToFrequency('0 9 * 1,2 *')).toEqual({ frequency: 'manual' })
expect(cronToFrequency('0 */2 * * *')).toEqual({ frequency: 'manual' })
})
test('hourly when minute is step but hour is wildcard', () => {
// */5 in minute + * in hour matches the first branch: hour === '*' && all wildcards
expect(cronToFrequency('*/5 * * * *')).toEqual({ frequency: 'hourly' })
})
test('out-of-range values return manual', () => {
expect(cronToFrequency('60 25 * * *')).toEqual({ frequency: 'manual' })
expect(cronToFrequency('99 99 * * *')).toEqual({ frequency: 'manual' })
})
test('pads hour and minute correctly', () => {
expect(cronToFrequency('5 9 * * *')).toEqual({ frequency: 'daily', time: '09:05' })
expect(cronToFrequency('30 0 * * *')).toEqual({ frequency: 'daily', time: '00:30' })
})
})
describe('FREQUENCY_OPTIONS', () => {
test('contains all expected frequencies', () => {
const values = FREQUENCY_OPTIONS.map(o => o.value)
expect(values).toContain('manual')
expect(values).toContain('hourly')
expect(values).toContain('daily')
expect(values).toContain('weekdays')
expect(values).toContain('weekly')
})
test('all have labels', () => {
for (const opt of FREQUENCY_OPTIONS) {
expect(typeof opt.label).toBe('string')
expect(opt.label.length).toBeGreaterThan(0)
}
})
})
describe('round-trip: frequency → cron → frequency', () => {
test('daily round-trip preserves data', () => {
const cron = frequencyToCron('daily', '09:00')
const result = cronToFrequency(cron)
expect(result).toEqual({ frequency: 'daily', time: '09:00' })
})
test('weekdays round-trip preserves data', () => {
const cron = frequencyToCron('weekdays', '08:30')
const result = cronToFrequency(cron)
expect(result).toEqual({ frequency: 'weekdays', time: '08:30' })
})
test('weekly round-trip preserves data', () => {
const cron = frequencyToCron('weekly', '17:00')
const result = cronToFrequency(cron)
expect(result).toEqual({ frequency: 'weekly', time: '17:00' })
})
test('hourly round-trip preserves minute', () => {
// frequencyToCron('hourly', '00:15') produces "15 * * * *"
// cronToFrequency('15 * * * *') returns 'hourly' (minute is numeric, hour is '*')
const cron = frequencyToCron('hourly', '00:15')
const result = cronToFrequency(cron)
expect(result).toEqual({ frequency: 'hourly' })
})
})

View File

@ -0,0 +1,204 @@
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
import { mkdir, writeFile, rm } from 'fs/promises'
import { join } from 'path'
import { randomUUID } from 'crypto'
// We'll test the updateCronTask by directly exercising the exported functions
// through a temporary directory approach.
// Note: These are integration tests that use actual filesystem operations.
describe('updateCronTask integration', () => {
const tmpDir = join('/tmp', `cron-test-${randomUUID().slice(0, 8)}`)
beforeEach(async () => {
// Create temp project structure
await mkdir(join(tmpDir, '.claude'), { recursive: true })
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
test('CRON_FILE_REL constant is correct', async () => {
// Import and verify the file relative path
const { getCronFilePath } = await import('../cronTasks.js')
const filePath = getCronFilePath(tmpDir)
expect(filePath).toContain('.claude')
expect(filePath).toContain('scheduled_tasks.json')
})
test('getCronFilePath returns correct path', async () => {
const { getCronFilePath } = await import('../cronTasks.js')
const filePath = getCronFilePath(tmpDir)
expect(filePath).toBe(join(tmpDir, '.claude', 'scheduled_tasks.json'))
})
test('getCronFilePath uses project root when no dir provided', async () => {
const { getCronFilePath } = await import('../cronTasks.js')
// Without dir, should use getProjectRoot() which is process.cwd()
// Just verify it returns a valid-looking path
const filePath = getCronFilePath()
expect(filePath).toContain('scheduled_tasks.json')
})
})
describe('CronTaskMeta type coverage', () => {
test('all UI fields are optional on CronTask', async () => {
// Verify all new fields exist on the type by creating tasks with them
const { addCronTask } = await import('../cronTasks.js')
// Create a task with all metadata fields (durable=true writes to disk in test dir)
const tmpDir = join('/tmp', `cron-meta-test-${randomUUID().slice(0, 8)}`)
await mkdir(join(tmpDir, '.claude'), { recursive: true })
const id = await addCronTask(
'0 9 * * *',
'test prompt',
true, // recurring
true, // durable (writes to disk)
undefined, // agentId
{
name: 'test-name',
description: 'test description',
folder: '/test/folder',
model: 'claude-opus-4-6',
permissionMode: 'ask',
worktree: false,
frequency: 'daily',
scheduledTime: '09:00',
},
)
expect(typeof id).toBe('string')
expect(id.length).toBe(8) // Short ID
// Clean up
await rm(tmpDir, { recursive: true, force: true })
})
})
describe('readCronTasks backward compatibility', () => {
test('handles empty file', async () => {
const { readCronTasks } = await import('../cronTasks.js')
const tmpDir = join('/tmp', `cron-empty-${randomUUID().slice(0, 8)}`)
await mkdir(join(tmpDir, '.claude'), { recursive: true })
const tasks = await readCronTasks(tmpDir)
expect(Array.isArray(tasks)).toBe(true)
expect(tasks.length).toBe(0)
await rm(tmpDir, { recursive: true, force: true })
})
test('skips malformed JSON', async () => {
const { readCronTasks } = await import('../cronTasks.js')
const tmpDir = join('/tmp', `cron-malformed-${randomUUID().slice(0, 8)}`)
await mkdir(join(tmpDir, '.claude'), { recursive: true })
// Write malformed JSON
const filePath = join(tmpDir, '.claude', 'scheduled_tasks.json')
await writeFile(filePath, 'not valid json{{{')
const tasks = await readCronTasks(tmpDir)
expect(tasks.length).toBe(0) // Malformed entries skipped
await rm(tmpDir, { recursive: true, force: true })
})
test('skips tasks with invalid cron strings', async () => {
const { readCronTasks } = await import('../cronTasks.js')
const tmpDir = join('/tmp', `cron-invalid-${randomUUID().slice(0, 8)}`)
await mkdir(join(tmpDir, '.claude'), { recursive: true })
// Write task with invalid cron
const filePath = join(tmpDir, '.claude', 'scheduled_tasks.json')
await writeFile(
filePath,
JSON.stringify({
tasks: [
{
id: 'abcd1234',
cron: 'invalid-cron',
prompt: 'test',
createdAt: Date.now(),
},
],
}),
)
const tasks = await readCronTasks(tmpDir)
expect(tasks.length).toBe(0) // Invalid cron skipped
await rm(tmpDir, { recursive: true, force: true })
})
test('preserves new fields when reading', async () => {
const { readCronTasks } = await import('../cronTasks.js')
const tmpDir = join('/tmp', `cron-preserve-${randomUUID().slice(0, 8)}`)
await mkdir(join(tmpDir, '.claude'), { recursive: true })
const filePath = join(tmpDir, '.claude', 'scheduled_tasks.json')
const task = {
id: 'abcd1234',
cron: '0 9 * * *',
prompt: 'test prompt',
createdAt: Date.now(),
recurring: true,
name: 'my-task',
description: 'A test task',
folder: '/project',
model: 'claude-sonnet-4-6',
permissionMode: 'bypass',
worktree: true,
frequency: 'daily',
scheduledTime: '09:00',
}
await writeFile(filePath, JSON.stringify({ tasks: [task] }))
const tasks = await readCronTasks(tmpDir)
expect(tasks.length).toBe(1)
expect(tasks[0].name).toBe('my-task')
expect(tasks[0].description).toBe('A test task')
expect(tasks[0].folder).toBe('/project')
expect(tasks[0].model).toBe('claude-sonnet-4-6')
expect(tasks[0].permissionMode).toBe('bypass')
expect(tasks[0].worktree).toBe(true)
expect(tasks[0].frequency).toBe('daily')
expect(tasks[0].scheduledTime).toBe('09:00')
await rm(tmpDir, { recursive: true, force: true })
})
})
describe('writeCronTasks strips runtime fields', () => {
test('strips durable and agentId on write', async () => {
const { readCronTasks, writeCronTasks } = await import('../cronTasks.js')
const tmpDir = join('/tmp', `cron-strip-${randomUUID().slice(0, 8)}`)
await mkdir(join(tmpDir, '.claude'), { recursive: true })
const taskWithRuntimeFields = {
id: 'abcd1234',
cron: '0 9 * * *',
prompt: 'test',
createdAt: Date.now(),
recurring: true,
durable: true, // runtime-only, should be stripped
agentId: 'agent-123', // runtime-only, should be stripped
name: 'test-task', // new field, should be preserved
}
await writeCronTasks([taskWithRuntimeFields as any], tmpDir)
// Read back and verify runtime fields are stripped
const filePath = join(tmpDir, '.claude', 'scheduled_tasks.json')
const { readFileSync } = await import('fs')
const raw = readFileSync(filePath, 'utf-8')
const parsed = JSON.parse(raw)
expect(parsed.tasks[0].durable).toBeUndefined()
expect(parsed.tasks[0].agentId).toBeUndefined()
expect(parsed.tasks[0].name).toBe('test-task')
await rm(tmpDir, { recursive: true, force: true })
})
})

119
src/utils/cronFrequency.ts Normal file
View File

@ -0,0 +1,119 @@
/**
* Converts between UI-friendly frequency settings and cron expressions.
* Used by the ScheduledTaskWizard for human-readable schedule configuration.
*/
/** Supported frequency values in the scheduled task UI. */
export type Frequency = 'manual' | 'hourly' | 'daily' | 'weekdays' | 'weekly'
export const FREQUENCY_OPTIONS: { label: string; value: Frequency }[] = [
{ label: 'Manual', value: 'manual' },
{ label: 'Hourly', value: 'hourly' },
{ label: 'Daily', value: 'daily' },
{ label: 'Weekdays', value: 'weekdays' },
{ label: 'Weekly', value: 'weekly' },
]
/** Validate "HH:MM" or "H:MM" time string. Returns [hour, minute] or null. */
function parseTime(time: string): { hour: number; minute: number } | null {
if (!time || typeof time !== 'string') return null
const [h, m] = time.trim().split(':')
const hour = parseInt(h ?? '', 10)
const minute = parseInt(m ?? '', 10)
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null
if (hour < 0 || hour > 23) return null
if (minute < 0 || minute > 59) return null
return { hour, minute }
}
/**
* Convert a frequency + time to a 5-field cron expression.
* Time format: "HH:MM" (24-hour). Defaults to "09:00" if not provided.
*
* - manual: no cron (returns empty string task runs on demand)
* - hourly: "0 * * * *"
* - daily + "09:00": "0 9 * * *"
* - weekdays + "09:00": "0 9 * * 1-5"
* - weekly + "09:00": "0 9 * * 1" (Monday)
*/
export function frequencyToCron(frequency: Frequency, time?: string): string {
if (frequency === 'manual') return ''
const parsed = parseTime(time ?? '09:00')
if (!parsed) return '' // malformed time → no-op
const { hour, minute } = parsed
switch (frequency) {
case 'hourly':
return `${minute} * * * *`
case 'daily':
return `${minute} ${hour} * * *`
case 'weekdays':
return `${minute} ${hour} * * 1-5`
case 'weekly':
return `${minute} ${hour} * * 1`
}
}
/**
* Best-effort reverse: guess the frequency + time from a cron expression.
* Returns the closest match; complex crons fall back to { frequency: 'manual' }.
*/
export function cronToFrequency(cron: string): {
frequency: Frequency
time?: string
} {
if (!cron || cron.trim() === '') return { frequency: 'manual' }
const parts = cron.trim().split(/\s+/)
if (parts.length !== 5) return { frequency: 'manual' }
const [minute, hour, dom, month, dow] = parts
// Hourly: "N * * * *"
if (hour === '*' && dom === '*' && month === '*' && dow === '*') {
return { frequency: 'hourly' }
}
// Check if it's a specific hour+minute pattern
if (
dom === '*' &&
month === '*' &&
isNumber(minute!) &&
isNumber(hour!)
) {
const h = parseInt(hour!, 10)
const m = parseInt(minute!, 10)
// Guard against out-of-range values (e.g. malformed cron in existing data)
if (h < 0 || h > 23 || m < 0 || m > 59) {
return { frequency: 'manual' }
}
const timeStr = `${pad(h)}:${pad(m)}`
// Weekdays: "M H * * 1-5"
if (dow === '1-5') {
return { frequency: 'weekdays', time: timeStr }
}
// Weekly: "M H * * 1"
if (dow === '1') {
return { frequency: 'weekly', time: timeStr }
}
// Daily: "M H * * *"
if (dow === '*') {
return { frequency: 'daily', time: timeStr }
}
}
// Can't map to a simple frequency
return { frequency: 'manual' }
}
function isNumber(s: string): boolean {
return /^\d+$/.test(s)
}
function pad(n: number): string {
return String(n).padStart(2, '0')
}

View File

@ -18,6 +18,7 @@ import {
getProjectRoot,
getSessionCronTasks,
removeSessionCronTasks,
setScheduledTasksEnabled,
} from '../bootstrap/state.js'
import { computeNextCronRun, parseCronExpression } from './cron.js'
import { logForDebugging } from './debug.js'
@ -57,8 +58,8 @@ export type CronTask = {
permanent?: boolean
/**
* Runtime-only flag. false session-scoped (never written to disk).
* File-backed tasks leave this undefined; writeCronTasks strips it so
* the on-disk shape stays { id, cron, prompt, createdAt, lastFiredAt?, recurring?, permanent? }.
* File-backed tasks leave this undefined; writeCronTasks strips it
* (along with agentId) so neither appears in the on-disk JSON.
*/
durable?: boolean
/**
@ -67,6 +68,34 @@ export type CronTask = {
* REPL's. Never written to disk (teammate crons are always session-only).
*/
agentId?: string
/** Human-readable task name (e.g. "daily-code-review"). */
name?: string
/** Task description (e.g. "Review yesterday's commits"). */
description?: string
/** Working directory for the task execution. */
folder?: string
/** Model to use (e.g. "claude-opus-4-6", "claude-sonnet-4-6"). */
model?: string
/** Permission mode: "ask" | "auto-accept" | "plan" | "bypass". */
permissionMode?: string
/** Whether to use git worktree for execution. */
worktree?: boolean
/** Schedule frequency for UI display: "manual" | "hourly" | "daily" | "weekdays" | "weekly". */
frequency?: string
/** Time string for scheduled execution (e.g. "09:00") — UI helper for daily/weekdays/weekly. */
scheduledTime?: string
}
/** Optional metadata fields for scheduled tasks (UI-driven creation). */
export type CronTaskMeta = {
name?: string
description?: string
folder?: string
model?: string
permissionMode?: string
worktree?: boolean
frequency?: string
scheduledTime?: string
}
type CronFile = { tasks: CronTask[] }
@ -134,6 +163,20 @@ export async function readCronTasks(dir?: string): Promise<CronTask[]> {
: {}),
...(t.recurring ? { recurring: true } : {}),
...(t.permanent ? { permanent: true } : {}),
...(typeof t.name === 'string' ? { name: t.name } : {}),
...(typeof t.description === 'string'
? { description: t.description }
: {}),
...(typeof t.folder === 'string' ? { folder: t.folder } : {}),
...(typeof t.model === 'string' ? { model: t.model } : {}),
...(typeof t.permissionMode === 'string'
? { permissionMode: t.permissionMode }
: {}),
...(typeof t.worktree === 'boolean' ? { worktree: t.worktree } : {}),
...(typeof t.frequency === 'string' ? { frequency: t.frequency } : {}),
...(typeof t.scheduledTime === 'string'
? { scheduledTime: t.scheduledTime }
: {}),
})
}
return out
@ -168,11 +211,10 @@ export async function writeCronTasks(
): Promise<void> {
const root = dir ?? getProjectRoot()
await mkdir(join(root, '.claude'), { recursive: true })
// Strip the runtime-only `durable` flag — everything on disk is durable
// by definition, and keeping the flag out means readCronTasks() naturally
// yields durable: undefined without having to set it explicitly.
// Strip runtime-only flags — everything on disk is durable by definition,
// and agentId is session-scoped (teammates don't persist across sessions).
const body: CronFile = {
tasks: tasks.map(({ durable: _durable, ...rest }) => rest),
tasks: tasks.map(({ durable: _durable, agentId: _agentId, ...rest }) => rest),
}
await writeFile(
getCronFilePath(root),
@ -197,16 +239,27 @@ export async function addCronTask(
recurring: boolean,
durable: boolean,
agentId?: string,
meta?: CronTaskMeta,
): Promise<string> {
// Short ID — 8 hex chars is plenty for MAX_JOBS=50, avoids slice/prefix
// juggling between the tool layer (shows short IDs) and disk.
const id = randomUUID().slice(0, 8)
const task = {
const task: CronTask = {
id,
cron,
prompt,
createdAt: Date.now(),
...(recurring ? { recurring: true } : {}),
...(meta?.name ? { name: meta.name } : {}),
...(meta?.description ? { description: meta.description } : {}),
...(meta?.folder ? { folder: meta.folder } : {}),
...(meta?.model ? { model: meta.model } : {}),
...(meta?.permissionMode
? { permissionMode: meta.permissionMode }
: {}),
...(meta?.worktree !== undefined ? { worktree: meta.worktree } : {}),
...(meta?.frequency ? { frequency: meta.frequency } : {}),
...(meta?.scheduledTime ? { scheduledTime: meta.scheduledTime } : {}),
}
if (!durable) {
addSessionCronTask({ ...task, ...(agentId ? { agentId } : {}) })
@ -295,6 +348,42 @@ export async function listAllCronTasks(dir?: string): Promise<CronTask[]> {
return [...fileTasks, ...sessionTasks]
}
/**
* Update fields of an existing task by id. Searches both the in-memory
* session store and the on-disk file. Returns true if the task was found
* and updated, false if no task with that id exists.
*
* When `dir` is undefined (REPL path), tries the session store first;
* if the id isn't there, falls through to disk.
*/
export async function updateCronTask(
id: string,
updates: Partial<Omit<CronTask, 'id' | 'createdAt'>>,
dir?: string,
): Promise<boolean> {
// Try session store first (REPL path only).
if (dir === undefined) {
const sessionTasks = getSessionCronTasks()
const sessionTask = sessionTasks.find(t => t.id === id)
if (sessionTask) {
Object.assign(sessionTask, updates)
// Re-signal the scheduler so it recalculates nextFireAt from the
// potentially-changed cron expression. The scheduler polls this flag.
setScheduledTasksEnabled(true)
return true
}
}
// Fall through to disk.
const tasks = await readCronTasks(dir)
const task = tasks.find(t => t.id === id)
if (!task) return false
Object.assign(task, updates)
await writeCronTasks(tasks, dir)
return true
}
/**
* Next fire time in epoch ms for a cron string, strictly after `fromMs`.
* Returns null if invalid or no match in the next 366 days.