diff --git a/src/cli/print.ts b/src/cli/print.ts index 9ec1cdda..4e1c7d9a 100644 --- a/src/cli/print.ts +++ b/src/cli/print.ts @@ -362,15 +362,9 @@ const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? (require('../proactive/index.js') as typeof import('../proactive/index.js')) : null -const cronSchedulerModule = feature('AGENT_TRIGGERS') - ? (require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js')) - : null -const cronJitterConfigModule = feature('AGENT_TRIGGERS') - ? (require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js')) - : null -const cronGate = feature('AGENT_TRIGGERS') - ? (require('../tools/ScheduleCronTool/prompt.js') as typeof import('../tools/ScheduleCronTool/prompt.js')) - : null +const cronSchedulerModule = require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js') +const cronJitterConfigModule = require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js') +const cronGate = require('../tools/ScheduleCronTool/prompt.js') as typeof import('../tools/ScheduleCronTool/prompt.js') const extractMemoriesModule = feature('EXTRACT_MEMORIES') ? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js')) : null @@ -2701,11 +2695,7 @@ function runHeadlessStreaming( // the end of run() picks up the queued command. let cronScheduler: import('../utils/cronScheduler.js').CronScheduler | null = null - if ( - feature('AGENT_TRIGGERS') && - cronSchedulerModule && - cronGate?.isKairosCronEnabled() - ) { + if (cronGate.isKairosCronEnabled()) { cronScheduler = cronSchedulerModule.createCronScheduler({ onFire: prompt => { if (inputClosed) return @@ -2727,8 +2717,8 @@ function runHeadlessStreaming( void run() }, isLoading: () => running || inputClosed, - getJitterConfig: cronJitterConfigModule?.getCronJitterConfig, - isKilled: () => !cronGate?.isKairosCronEnabled(), + getJitterConfig: cronJitterConfigModule.getCronJitterConfig, + isKilled: () => !cronGate.isKairosCronEnabled(), }) cronScheduler.start() } diff --git a/src/constants/tools.ts b/src/constants/tools.ts index 5db0afb4..18263a23 100644 --- a/src/constants/tools.ts +++ b/src/constants/tools.ts @@ -82,9 +82,9 @@ export const IN_PROCESS_TEAMMATE_ALLOWED_TOOLS = new Set([ SEND_MESSAGE_TOOL_NAME, // Teammate-created crons are tagged with the creating agentId and routed to // that teammate's pendingUserMessages queue (see useScheduledTasks.ts). - ...(feature('AGENT_TRIGGERS') - ? [CRON_CREATE_TOOL_NAME, CRON_DELETE_TOOL_NAME, CRON_LIST_TOOL_NAME] - : []), + CRON_CREATE_TOOL_NAME, + CRON_DELETE_TOOL_NAME, + CRON_LIST_TOOL_NAME, ]) /* diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index bc5f1134..90537dbb 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -196,7 +196,7 @@ const PROACTIVE_NO_OP_SUBSCRIBE = (_cb: () => void) => () => { }; const PROACTIVE_FALSE = () => false; const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false; const useProactive = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null; -const useScheduledTasks = feature('AGENT_TRIGGERS') ? require('../hooks/useScheduledTasks.js').useScheduledTasks : null; +const useScheduledTasks = require('../hooks/useScheduledTasks.js').useScheduledTasks; /* eslint-enable @typescript-eslint/no-require-imports */ import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'; import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js'; @@ -4076,21 +4076,13 @@ export function REPL({ }); // Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List) - if (feature('AGENT_TRIGGERS')) { - // Assistant mode bypasses the isLoading gate (the proactive tick → - // Sleep → tick loop would otherwise starve the scheduler). - // kairosEnabled is set once in initialState (main.tsx) and never mutated — no - // subscription needed. The tengu_kairos_cron runtime gate is checked inside - // useScheduledTasks's effect (not here) since wrapping a hook call in a dynamic - // condition would break rules-of-hooks. - const assistantMode = store.getState().kairosEnabled; - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useScheduledTasks!({ - isLoading, - assistantMode, - setMessages - }); - } + // and session-only /loop runs. + const assistantMode = store.getState().kairosEnabled; + useScheduledTasks({ + isLoading, + assistantMode, + setMessages + }); // Note: Permission polling is now handled by useInboxPoller // - Workers receive permission responses via mailbox messages diff --git a/src/skills/bundled/index.ts b/src/skills/bundled/index.ts index 3a18982b..08db32d0 100644 --- a/src/skills/bundled/index.ts +++ b/src/skills/bundled/index.ts @@ -4,6 +4,7 @@ import { registerBatchSkill } from './batch.js' import { registerClaudeInChromeSkill } from './claudeInChrome.js' import { registerDebugSkill } from './debug.js' import { registerKeybindingsSkill } from './keybindings.js' +import { registerLoopSkill } from './loop.js' import { registerSimplifySkill } from './simplify.js' import { registerUpdateConfigSkill } from './updateConfig.js' @@ -34,15 +35,10 @@ export function initBundledSkills(): void { /* eslint-enable @typescript-eslint/no-require-imports */ registerHunterSkill() } - if (feature('AGENT_TRIGGERS')) { - /* eslint-disable @typescript-eslint/no-require-imports */ - const { registerLoopSkill } = require('./loop.js') - /* eslint-enable @typescript-eslint/no-require-imports */ - // /loop's isEnabled delegates to isKairosCronEnabled() — same lazy - // per-invocation pattern as the cron tools. Registered unconditionally; - // the skill's own isEnabled callback decides visibility. - registerLoopSkill() - } + // /loop's isEnabled delegates to isKairosCronEnabled() — registered + // unconditionally so the static import is bundled; visibility is gated + // at runtime by the isEnabled callback. + registerLoopSkill() if (feature('AGENT_TRIGGERS_REMOTE')) { /* eslint-disable @typescript-eslint/no-require-imports */ const { diff --git a/src/skills/bundled/loop.test.ts b/src/skills/bundled/loop.test.ts new file mode 100644 index 00000000..0d2b4386 --- /dev/null +++ b/src/skills/bundled/loop.test.ts @@ -0,0 +1,125 @@ +import { afterEach, expect, test } from 'bun:test' + +import { clearBundledSkills, getBundledSkills } from '../bundledSkills.js' +import { registerLoopSkill } from './loop.js' + +afterEach(() => { + clearBundledSkills() +}) + +test('bare /loop returns dynamic maintenance instructions', async () => { + registerLoopSkill() + + const skill = getBundledSkills().find(command => command.name === 'loop') + expect(skill).toBeDefined() + expect(skill?.type).toBe('prompt') + + const blocks = await skill!.getPromptForCommand('', {} as never) + const text = (blocks[0] as { text: string }).text + + expect(text).toContain('# /loop — dynamic rescheduling') + expect(text).toContain('If .claude/loop.md exists, read it and use it.') + expect(text).toContain('continue any unfinished work from the conversation') + expect(text).toContain('Set the scheduled prompt to this exact text so the next iteration stays in dynamic mode:') + expect(text).toContain('/loop') +}) + +test('prompt-only /loop returns dynamic rescheduling instructions', async () => { + registerLoopSkill() + + const skill = getBundledSkills().find(command => command.name === 'loop') + const blocks = await skill!.getPromptForCommand('check the deploy', {} as never) + const text = (blocks[0] as { text: string }).text + + expect(text).toContain('# /loop — dynamic rescheduling') + expect(text).toContain('check the deploy') + expect(text).toContain('choose the next delay dynamically between 1 minute and 1 hour') + expect(text).toContain('/loop check the deploy') +}) + +test('interval /loop returns fixed recurring instructions', async () => { + registerLoopSkill() + + const skill = getBundledSkills().find(command => command.name === 'loop') + const blocks = await skill!.getPromptForCommand('5m check the deploy', {} as never) + const text = (blocks[0] as { text: string }).text + + expect(text).toContain('# /loop — fixed recurring interval') + expect(text).toContain('Requested interval:') + expect(text).toContain('5m') + expect(text).toContain('Call CronCreate') + expect(text).toContain('recurring: true') + expect(text).toContain('Immediately execute the effective prompt now') +}) + +test('interval-only /loop becomes fixed maintenance mode', async () => { + registerLoopSkill() + + const skill = getBundledSkills().find(command => command.name === 'loop') + const blocks = await skill!.getPromptForCommand('15m', {} as never) + const text = (blocks[0] as { text: string }).text + + expect(text).toContain('# /loop — fixed recurring interval') + expect(text).toContain('15m') + expect(text).toContain('This is a maintenance loop with no explicit prompt.') + expect(text).toContain('Scheduled maintenance loop iteration.') +}) + +test('trailing every clause parses interval and prompt', async () => { + registerLoopSkill() + + const skill = getBundledSkills().find(command => command.name === 'loop') + const blocks = await skill!.getPromptForCommand('check the deploy every 20m', {} as never) + const text = (blocks[0] as { text: string }).text + + expect(text).toContain('# /loop — fixed recurring interval') + expect(text).toContain('20m') + expect(text).toContain('check the deploy') +}) + +test('trailing every clause with word unit parses correctly', async () => { + registerLoopSkill() + + const skill = getBundledSkills().find(command => command.name === 'loop') + const blocks = await skill!.getPromptForCommand('run tests every 5 minutes', {} as never) + const text = (blocks[0] as { text: string }).text + + expect(text).toContain('# /loop — fixed recurring interval') + expect(text).toContain('5m') + expect(text).toContain('run tests') +}) + +test('"check every PR" is not treated as an interval', async () => { + registerLoopSkill() + + const skill = getBundledSkills().find(command => command.name === 'loop') + const blocks = await skill!.getPromptForCommand('check every PR', {} as never) + const text = (blocks[0] as { text: string }).text + + expect(text).toContain('# /loop — dynamic rescheduling') + expect(text).toContain('check every PR') +}) + +test('human-readable hour unit parses correctly', async () => { + registerLoopSkill() + + const skill = getBundledSkills().find(command => command.name === 'loop') + const blocks = await skill!.getPromptForCommand('2h check logs', {} as never) + const text = (blocks[0] as { text: string }).text + + expect(text).toContain('# /loop — fixed recurring interval') + expect(text).toContain('2h') + expect(text).toContain('check logs') +}) + +test('prompt delimiters are present and unambiguous', async () => { + registerLoopSkill() + + const skill = getBundledSkills().find(command => command.name === 'loop') + const blocks = await skill!.getPromptForCommand('5m say hi', {} as never) + const text = (blocks[0] as { text: string }).text + + expect(text).toContain('--- BEGIN PROMPT ---') + expect(text).toContain('say hi') + expect(text).toContain('--- END PROMPT ---') +}) diff --git a/src/skills/bundled/loop.ts b/src/skills/bundled/loop.ts index ccc472da..1c47bdd0 100644 --- a/src/skills/bundled/loop.ts +++ b/src/skills/bundled/loop.ts @@ -6,87 +6,218 @@ import { } from '../../tools/ScheduleCronTool/prompt.js' import { registerBundledSkill } from '../bundledSkills.js' -const DEFAULT_INTERVAL = '10m' +type LoopMode = + | 'dynamic-prompt' + | 'dynamic-maintenance' + | 'fixed-prompt' + | 'fixed-maintenance' -const USAGE_MESSAGE = `Usage: /loop [interval] +type ParsedLoopArgs = { + mode: LoopMode + interval?: string + prompt?: string +} -Run a prompt or slash command on a recurring interval. +const DYNAMIC_MIN_DELAY = '1 minute' +const DYNAMIC_MAX_DELAY = '1 hour' -Intervals: Ns, Nm, Nh, Nd (e.g. 5m, 30m, 2h, 1d). Minimum granularity is 1 minute. -If no interval is specified, defaults to ${DEFAULT_INTERVAL}. +const MAINTENANCE_PROMPT = `Scheduled maintenance loop iteration. -Examples: - /loop 5m /babysit-prs - /loop 30m check the deploy - /loop 1h /standup 1 - /loop check the deploy (defaults to ${DEFAULT_INTERVAL}) - /loop check the deploy every 20m` +If .claude/loop.md exists, read it and follow it. +Otherwise, if ~/.claude/loop.md exists, read it and follow it. +Otherwise: +- continue any unfinished work from the conversation +- tend to the current branch's pull request: review comments, failed CI runs, merge conflicts +- run cleanup passes such as bug hunts or simplification when nothing else is pending -function buildPrompt(args: string): string { - return `# /loop — schedule a recurring prompt +Do not start new initiatives outside that scope. +Irreversible actions such as pushing or deleting only proceed when they continue something the transcript already authorized.` -Parse the input below into \`[interval] \` and schedule it with ${CRON_CREATE_TOOL_NAME}. +function normalizeIntervalUnit(rawUnit: string): 's' | 'm' | 'h' | 'd' | null { + const unit = rawUnit.toLowerCase() + if (['s', 'sec', 'secs', 'second', 'seconds'].includes(unit)) return 's' + if (['m', 'min', 'mins', 'minute', 'minutes'].includes(unit)) return 'm' + if (['h', 'hr', 'hrs', 'hour', 'hours'].includes(unit)) return 'h' + if (['d', 'day', 'days'].includes(unit)) return 'd' + return null +} -## Parsing (in priority order) +function parseIntervalToken(token: string): string | null { + const match = token.trim().match(/^(\d+)\s*([a-zA-Z]+)$/) + if (!match) return null + const value = Number.parseInt(match[1]!, 10) + if (!Number.isFinite(value) || value < 1) return null + const unit = normalizeIntervalUnit(match[2]!) + if (!unit) return null + return `${value}${unit}` +} -1. **Leading token**: if the first whitespace-delimited token matches \`^\\d+[smhd]$\` (e.g. \`5m\`, \`2h\`), that's the interval; the rest is the prompt. -2. **Trailing "every" clause**: otherwise, if the input ends with \`every \` or \`every \` (e.g. \`every 20m\`, \`every 5 minutes\`, \`every 2 hours\`), extract that as the interval and strip it from the prompt. Only match when what follows "every" is a time expression — \`check every PR\` has no interval. -3. **Default**: otherwise, interval is \`${DEFAULT_INTERVAL}\` and the entire input is the prompt. +function parseTrailingEveryClause(input: string): { + prompt: string + interval: string +} | null { + const match = input.match(/^(.*?)(?:\s+every\s+)(\d+)\s*([a-zA-Z]+)\s*$/i) + if (!match) return null + const interval = parseIntervalToken(`${match[2]!}${match[3]!}`) + if (!interval) return null + return { + prompt: match[1]!.trim(), + interval, + } +} -If the resulting prompt is empty, show usage \`/loop [interval] \` and stop — do not call ${CRON_CREATE_TOOL_NAME}. +function parseLoopArgs(args: string): ParsedLoopArgs { + const trimmed = args.trim() + if (!trimmed) return { mode: 'dynamic-maintenance' } -Examples: -- \`5m /babysit-prs\` → interval \`5m\`, prompt \`/babysit-prs\` (rule 1) -- \`check the deploy every 20m\` → interval \`20m\`, prompt \`check the deploy\` (rule 2) -- \`run tests every 5 minutes\` → interval \`5m\`, prompt \`run tests\` (rule 2) -- \`check the deploy\` → interval \`${DEFAULT_INTERVAL}\`, prompt \`check the deploy\` (rule 3) -- \`check every PR\` → interval \`${DEFAULT_INTERVAL}\`, prompt \`check every PR\` (rule 3 — "every" not followed by time) -- \`5m\` → empty prompt → show usage + const bareInterval = parseIntervalToken(trimmed) + if (bareInterval) { + return { mode: 'fixed-maintenance', interval: bareInterval } + } -## Interval → cron + const [firstToken, ...restTokens] = trimmed.split(/\s+/) + const leadingInterval = parseIntervalToken(firstToken ?? '') + if (leadingInterval) { + const prompt = restTokens.join(' ').trim() + if (!prompt) return { mode: 'fixed-maintenance', interval: leadingInterval } + return { + mode: 'fixed-prompt', + interval: leadingInterval, + prompt, + } + } -Supported suffixes: \`s\` (seconds, rounded up to nearest minute, min 1), \`m\` (minutes), \`h\` (hours), \`d\` (days). Convert: + const trailingEvery = parseTrailingEveryClause(trimmed) + if (trailingEvery) { + if (!trailingEvery.prompt) { + return { + mode: 'fixed-maintenance', + interval: trailingEvery.interval, + } + } + return { + mode: 'fixed-prompt', + interval: trailingEvery.interval, + prompt: trailingEvery.prompt, + } + } -| Interval pattern | Cron expression | Notes | -|-----------------------|---------------------|------------------------------------------| -| \`Nm\` where N ≤ 59 | \`*/N * * * *\` | every N minutes | -| \`Nm\` where N ≥ 60 | \`0 */H * * *\` | round to hours (H = N/60, must divide 24)| -| \`Nh\` where N ≤ 23 | \`0 */N * * *\` | every N hours | -| \`Nd\` | \`0 0 */N * *\` | every N days at midnight local | -| \`Ns\` | treat as \`ceil(N/60)m\` | cron minimum granularity is 1 minute | + return { + mode: 'dynamic-prompt', + prompt: trimmed, + } +} -**If the interval doesn't cleanly divide its unit** (e.g. \`7m\` → \`*/7 * * * *\` gives uneven gaps at :56→:00; \`90m\` → 1.5h which cron can't express), pick the nearest clean interval and tell the user what you rounded to before scheduling. +function buildFixedPrompt(parsed: ParsedLoopArgs): string { + const targetInstructions = parsed.prompt + ? `Use this prompt verbatim for both the immediate run and the recurring scheduled task: -## Action +--- BEGIN PROMPT --- +${parsed.prompt} +--- END PROMPT --- +` + : `This is a maintenance loop with no explicit prompt. -1. Call ${CRON_CREATE_TOOL_NAME} with: - - \`cron\`: the expression from the table above - - \`prompt\`: the parsed prompt from above, verbatim (slash commands are passed through unchanged) - - \`recurring\`: \`true\` -2. Briefly confirm: what's scheduled, the cron expression, the human-readable cadence, that recurring tasks auto-expire after ${DEFAULT_MAX_AGE_DAYS} days, and that they can cancel sooner with ${CRON_DELETE_TOOL_NAME} (include the job ID). -3. **Then immediately execute the parsed prompt now** — don't wait for the first cron fire. If it's a slash command, invoke it via the Skill tool; otherwise act on it directly. +For the recurring scheduled task, use this exact maintenance prompt body: -## Input +--- BEGIN MAINTENANCE PROMPT --- +${MAINTENANCE_PROMPT} +--- END MAINTENANCE PROMPT --- +` -${args}` + return `# /loop — fixed recurring interval + +The user invoked /loop with a fixed interval. + +Requested interval: ${parsed.interval} + +${targetInstructions} +## Instructions + +1. Convert the requested interval to a recurring cron expression. + - Supported suffixes: s, m, h, d. + - Seconds must be rounded up to the nearest minute because cron has minute granularity. + - If the requested interval does not map cleanly to cron cadence, choose the nearest clean recurring interval and tell the user what you picked. +2. Call ${CRON_CREATE_TOOL_NAME} with: + - the recurring cron expression + - the effective prompt body above + - recurring: true + - durable: false +3. Briefly confirm what was scheduled, the cron expression, the human cadence, that recurring tasks auto-expire after ${DEFAULT_MAX_AGE_DAYS} days, and that the user can cancel sooner with ${CRON_DELETE_TOOL_NAME} using the returned job ID. +4. Immediately execute the effective prompt now — do not wait for the first cron fire. + - If the effective prompt starts with a slash command, invoke it via the Skill tool. + - Otherwise, act on it directly. +` +} + +function buildDynamicPrompt(parsed: ParsedLoopArgs): string { + const effectivePromptInstructions = parsed.prompt + ? `Use this prompt verbatim as the effective prompt for this iteration: + +--- BEGIN PROMPT --- +${parsed.prompt} +--- END PROMPT --- +` + : `This is a maintenance loop with no explicit prompt. + +Determine the effective prompt in this order: +1. If .claude/loop.md exists, read it and use it. +2. Otherwise, if ~/.claude/loop.md exists, read it and use it. +3. Otherwise, use this built-in maintenance prompt: + +--- BEGIN MAINTENANCE PROMPT --- +${MAINTENANCE_PROMPT} +--- END MAINTENANCE PROMPT --- +` + + const reschedulePrompt = parsed.prompt ? `/loop ${parsed.prompt}` : '/loop' + + return `# /loop — dynamic rescheduling + +The user invoked /loop without a fixed interval. + +${effectivePromptInstructions} +## Instructions + +1. Execute the effective prompt now. + - If it starts with a slash command, invoke it via the Skill tool. + - Otherwise, act on it directly. +2. After the work finishes, choose the next delay dynamically between ${DYNAMIC_MIN_DELAY} and ${DYNAMIC_MAX_DELAY}. + - Use shorter delays while active work is progressing or likely to change soon. + - Use longer delays when the situation is quiet or stable. +3. Briefly tell the user the chosen delay and the reason. +4. Schedule exactly one session-only follow-up run with ${CRON_CREATE_TOOL_NAME}. + - Use recurring: false. + - Use durable: false. + - Pin the cron expression to a specific future local-time minute that matches the chosen delay. + - Set the scheduled prompt to this exact text so the next iteration stays in dynamic mode: + +--- BEGIN SCHEDULED PROMPT --- +${reschedulePrompt} +--- END SCHEDULED PROMPT --- + +5. Confirm the next run time and the returned job ID. +6. Do not create a recurring cron for this mode. +` } export function registerLoopSkill(): void { registerBundledSkill({ name: 'loop', description: - 'Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo, defaults to 10m)', + 'Run a prompt on a fixed interval or dynamically reschedule it, including bare maintenance-mode loops.', whenToUse: - 'When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. "check the deploy every 5 minutes", "keep running /babysit-prs"). Do NOT invoke for one-off tasks.', - argumentHint: '[interval] ', + 'When the user wants to poll for status, babysit a workflow, run recurring maintenance, or keep re-running a prompt within the current session.', + argumentHint: '[interval] [prompt]', userInvocable: true, isEnabled: isKairosCronEnabled, async getPromptForCommand(args) { - const trimmed = args.trim() - if (!trimmed) { - return [{ type: 'text', text: USAGE_MESSAGE }] - } - return [{ type: 'text', text: buildPrompt(trimmed) }] + const parsed = parseLoopArgs(args) + const text = + parsed.mode === 'fixed-prompt' || parsed.mode === 'fixed-maintenance' + ? buildFixedPrompt(parsed) + : buildDynamicPrompt(parsed) + return [{ type: 'text', text }] }, }) } diff --git a/src/tools.ts b/src/tools.ts index a409eb64..da0d573c 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -26,13 +26,11 @@ const SleepTool = feature('PROACTIVE') || feature('KAIROS') ? require('./tools/SleepTool/SleepTool.js').SleepTool : null -const cronTools = feature('AGENT_TRIGGERS') - ? [ - require('./tools/ScheduleCronTool/CronCreateTool.js').CronCreateTool, - require('./tools/ScheduleCronTool/CronDeleteTool.js').CronDeleteTool, - require('./tools/ScheduleCronTool/CronListTool.js').CronListTool, - ] - : [] +const cronTools = [ + require('./tools/ScheduleCronTool/CronCreateTool.js').CronCreateTool, + require('./tools/ScheduleCronTool/CronDeleteTool.js').CronDeleteTool, + require('./tools/ScheduleCronTool/CronListTool.js').CronListTool, +] const RemoteTriggerTool = feature('AGENT_TRIGGERS_REMOTE') ? require('./tools/RemoteTriggerTool/RemoteTriggerTool.js').RemoteTriggerTool : null diff --git a/src/tools/ScheduleCronTool/prompt.ts b/src/tools/ScheduleCronTool/prompt.ts index 33ff2a20..d873eed2 100644 --- a/src/tools/ScheduleCronTool/prompt.ts +++ b/src/tools/ScheduleCronTool/prompt.ts @@ -9,39 +9,35 @@ export const DEFAULT_MAX_AGE_DAYS = DEFAULT_CRON_JITTER_CONFIG.recurringMaxAgeMs / (24 * 60 * 60 * 1000) /** - * Unified gate for the cron scheduling system. Combines the build-time - * `feature('AGENT_TRIGGERS')` flag (dead code elimination) with the runtime - * `tengu_kairos_cron` GrowthBook gate on a 5-minute refresh window. + * Unified gate for the cron scheduling system. * - * AGENT_TRIGGERS is independently shippable from KAIROS — the cron module - * graph (cronScheduler/cronTasks/cronTasksLock/cron.ts + the three tools + - * /loop skill) has zero imports into src/assistant/ and no feature('KAIROS') - * calls. The REPL.tsx kairosEnabled read is safe: - * kairosEnabled is unconditionally in AppStateStore with default false, so - * when KAIROS is off the scheduler just gets assistantMode: false. + * Open builds (USER_TYPE !== 'ant') enable cron unconditionally — the + * cron tools and /loop skill are registered without the AGENT_TRIGGERS + * build flag, so this gate is the sole runtime switch. Set the env var + * `CLAUDE_CODE_DISABLE_CRON=1` to turn it off locally. + * + * Anthropic-internal (ant) builds additionally consult the + * `tengu_kairos_cron` GrowthBook gate on a 5-minute refresh window, + * serving as a fleet-wide kill switch. * * Called from Tool.isEnabled() (lazy, post-init) and inside useEffect / * imperative setup, never at module scope — so the disk cache has had a * chance to populate. * - * The default is `true` — /loop is GA (announced in changelog). GrowthBook - * is disabled for Bedrock/Vertex/Foundry and when DISABLE_TELEMETRY / - * CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC are set; a `false` default would - * break /loop for those users (GH #31759). The GB gate now serves purely as - * a fleet-wide kill switch — flipping it to `false` stops already-running - * schedulers on their next isKilled poll tick, not just new ones. - * * `CLAUDE_CODE_DISABLE_CRON` is a local override that wins over GB. */ export function isKairosCronEnabled(): boolean { - return feature('AGENT_TRIGGERS') - ? !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_CRON) && - getFeatureValue_CACHED_WITH_REFRESH( - 'tengu_kairos_cron', - true, - KAIROS_CRON_REFRESH_MS, - ) - : false + if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_CRON)) return false + + // OpenClaude open builds do not rely on Anthropic's internal runtime gates. + // Expose cron support by default unless explicitly disabled. + if (process.env.USER_TYPE !== 'ant') return true + + return getFeatureValue_CACHED_WITH_REFRESH( + 'tengu_kairos_cron', + true, + KAIROS_CRON_REFRESH_MS, + ) } /**