/ tools / ScheduleCronTool / CronCreateTool.ts
CronCreateTool.ts
  1  import { z } from 'zod/v4'
  2  import { setScheduledTasksEnabled } from '../../bootstrap/state.js'
  3  import type { ValidationResult } from '../../Tool.js'
  4  import { buildTool, type ToolDef } from '../../Tool.js'
  5  import { cronToHuman, parseCronExpression } from '../../utils/cron.js'
  6  import {
  7    addCronTask,
  8    getCronFilePath,
  9    listAllCronTasks,
 10    nextCronRunMs,
 11  } from '../../utils/cronTasks.js'
 12  import { lazySchema } from '../../utils/lazySchema.js'
 13  import { semanticBoolean } from '../../utils/semanticBoolean.js'
 14  import { getTeammateContext } from '../../utils/teammateContext.js'
 15  import {
 16    buildCronCreateDescription,
 17    buildCronCreatePrompt,
 18    CRON_CREATE_TOOL_NAME,
 19    DEFAULT_MAX_AGE_DAYS,
 20    isDurableCronEnabled,
 21    isKairosCronEnabled,
 22  } from './prompt.js'
 23  import { renderCreateResultMessage, renderCreateToolUseMessage } from './UI.js'
 24  
 25  const MAX_JOBS = 50
 26  
 27  const inputSchema = lazySchema(() =>
 28    z.strictObject({
 29      cron: z
 30        .string()
 31        .describe(
 32          'Standard 5-field cron expression in local time: "M H DoM Mon DoW" (e.g. "*/5 * * * *" = every 5 minutes, "30 14 28 2 *" = Feb 28 at 2:30pm local once).',
 33        ),
 34      prompt: z.string().describe('The prompt to enqueue at each fire time.'),
 35      recurring: semanticBoolean(z.boolean().optional()).describe(
 36        `true (default) = fire on every cron match until deleted or auto-expired after ${DEFAULT_MAX_AGE_DAYS} days. false = fire once at the next match, then auto-delete. Use false for "remind me at X" one-shot requests with pinned minute/hour/dom/month.`,
 37      ),
 38      durable: semanticBoolean(z.boolean().optional()).describe(
 39        'true = persist to .claude/scheduled_tasks.json and survive restarts. false (default) = in-memory only, dies when this Claude session ends. Use true only when the user asks the task to survive across sessions.',
 40      ),
 41    }),
 42  )
 43  type InputSchema = ReturnType<typeof inputSchema>
 44  
 45  const outputSchema = lazySchema(() =>
 46    z.object({
 47      id: z.string(),
 48      humanSchedule: z.string(),
 49      recurring: z.boolean(),
 50      durable: z.boolean().optional(),
 51    }),
 52  )
 53  type OutputSchema = ReturnType<typeof outputSchema>
 54  export type CreateOutput = z.infer<OutputSchema>
 55  
 56  export const CronCreateTool = buildTool({
 57    name: CRON_CREATE_TOOL_NAME,
 58    searchHint: 'schedule a recurring or one-shot prompt',
 59    maxResultSizeChars: 100_000,
 60    shouldDefer: true,
 61    get inputSchema(): InputSchema {
 62      return inputSchema()
 63    },
 64    get outputSchema(): OutputSchema {
 65      return outputSchema()
 66    },
 67    isEnabled() {
 68      return isKairosCronEnabled()
 69    },
 70    toAutoClassifierInput(input) {
 71      return `${input.cron}: ${input.prompt}`
 72    },
 73    async description() {
 74      return buildCronCreateDescription(isDurableCronEnabled())
 75    },
 76    async prompt() {
 77      return buildCronCreatePrompt(isDurableCronEnabled())
 78    },
 79    getPath() {
 80      return getCronFilePath()
 81    },
 82    async validateInput(input): Promise<ValidationResult> {
 83      if (!parseCronExpression(input.cron)) {
 84        return {
 85          result: false,
 86          message: `Invalid cron expression '${input.cron}'. Expected 5 fields: M H DoM Mon DoW.`,
 87          errorCode: 1,
 88        }
 89      }
 90      if (nextCronRunMs(input.cron, Date.now()) === null) {
 91        return {
 92          result: false,
 93          message: `Cron expression '${input.cron}' does not match any calendar date in the next year.`,
 94          errorCode: 2,
 95        }
 96      }
 97      const tasks = await listAllCronTasks()
 98      if (tasks.length >= MAX_JOBS) {
 99        return {
100          result: false,
101          message: `Too many scheduled jobs (max ${MAX_JOBS}). Cancel one first.`,
102          errorCode: 3,
103        }
104      }
105      // Teammates don't persist across sessions, so a durable teammate cron
106      // would orphan on restart (agentId would point to a nonexistent teammate).
107      if (input.durable && getTeammateContext()) {
108        return {
109          result: false,
110          message:
111            'durable crons are not supported for teammates (teammates do not persist across sessions)',
112          errorCode: 4,
113        }
114      }
115      return { result: true }
116    },
117    async call({ cron, prompt, recurring = true, durable = false }) {
118      // Kill switch forces session-only; schema stays stable so the model sees
119      // no validation errors when the gate flips mid-session.
120      const effectiveDurable = durable && isDurableCronEnabled()
121      const id = await addCronTask(
122        cron,
123        prompt,
124        recurring,
125        effectiveDurable,
126        getTeammateContext()?.agentId,
127      )
128      // Enable the scheduler so the task fires in this session. The
129      // useScheduledTasks hook polls this flag and will start watching
130      // on the next tick. For durable: false tasks the file never changes
131      // — check() reads the session store directly — but the enable flag
132      // is still what starts the tick loop.
133      setScheduledTasksEnabled(true)
134      return {
135        data: {
136          id,
137          humanSchedule: cronToHuman(cron),
138          recurring,
139          durable: effectiveDurable,
140        },
141      }
142    },
143    mapToolResultToToolResultBlockParam(output, toolUseID) {
144      const where = output.durable
145        ? 'Persisted to .claude/scheduled_tasks.json'
146        : 'Session-only (not written to disk, dies when Claude exits)'
147      return {
148        tool_use_id: toolUseID,
149        type: 'tool_result',
150        content: output.recurring
151          ? `Scheduled recurring job ${output.id} (${output.humanSchedule}). ${where}. Auto-expires after ${DEFAULT_MAX_AGE_DAYS} days. Use CronDelete to cancel sooner.`
152          : `Scheduled one-shot task ${output.id} (${output.humanSchedule}). ${where}. It will fire once then auto-delete.`,
153      }
154    },
155    renderToolUseMessage: renderCreateToolUseMessage,
156    renderToolResultMessage: renderCreateResultMessage,
157  } satisfies ToolDef<InputSchema, CreateOutput>)