feat: implement /loop command with fixed and dynamic scheduling (#621)

* feat: implement /loop command with fixed and dynamic scheduling modes

Enable cron tools and /loop skill without the AGENT_TRIGGERS build flag
by removing feature guards from tools.ts, REPL.tsx, and skill registration.
The isKairosCronEnabled() runtime gate now enables cron unconditionally for
open builds while preserving the GrowthBook kill switch for ant builds.

The /loop skill supports four modes: fixed-interval with prompt, fixed-interval
maintenance, dynamic-prompt (self-pacing), and dynamic maintenance (bare /loop).

* chore: remove unused DEFAULT_INTERVAL constant from loop skill

* revert: drop infra changes, scope PR to /loop skill rewrite only

The cron activation layer (AGENT_TRIGGERS guard removal, isKairosCronEnabled
hardcode) is covered by an in-flight stack (#633, #639). Scope this PR to
just the loop.ts rewrite and its tests so it can land cleanly on top.

* fix: restore infra changes needed for /loop in open build

Bun's constant folder evaluates feature('AGENT_TRIGGERS') at bundle time
through the bun:bundle shim — even when the flag is flipped to true in
build.ts, the folded value is cached from the previous build and stays false.
This means the feature-gated require() blocks for cron tools, useScheduledTasks,
and loop skill registration all compile to dead code regardless of the flag.

Fix by removing the AGENT_TRIGGERS guards from the specific paths /loop needs:
- tools.ts: cron tools always registered (isEnabled gates visibility)
- REPL.tsx: useScheduledTasks always mounted
- index.ts: registerLoopSkill via static import, called unconditionally
- prompt.ts: isKairosCronEnabled() bypasses feature flag for non-ant builds

* fix: replace backslash line continuations with explicit delimiters in loop prompts

The backslash-newline sequences inside template literals were acting as
line continuations, collapsing newlines and merging prompt content with
surrounding instruction text. Replace with --- BEGIN/END --- markers
for unambiguous delimiting.

Also add tests for trailing "every" clause parsing, human-readable unit
normalization, and the non-interval "check every PR" case.

* fix: remove remaining AGENT_TRIGGERS guards from print.ts and constants/tools.ts

Completes the cron guard removal started in the previous commit.
The cron scheduler in non-interactive (-p) mode was dead because
print.ts still gated cronSchedulerModule/cronGate requires behind
feature('AGENT_TRIGGERS'), which Bun constant-folds to false in open
builds. Similarly, cron tool names were absent from
IN_PROCESS_TEAMMATE_ALLOWED_TOOLS.

Remove all three guards so the scheduler initialises (gated at runtime
by isKairosCronEnabled) and cron tools are allowed for in-process
teammates in all builds.
This commit is contained in:
Khaled Moayad
2026-04-13 12:28:42 +02:00
committed by GitHub
parent 30c866d31a
commit 64298a663f
8 changed files with 356 additions and 128 deletions

View File

@@ -362,15 +362,9 @@ const proactiveModule =
feature('PROACTIVE') || feature('KAIROS') feature('PROACTIVE') || feature('KAIROS')
? (require('../proactive/index.js') as typeof import('../proactive/index.js')) ? (require('../proactive/index.js') as typeof import('../proactive/index.js'))
: null : null
const cronSchedulerModule = feature('AGENT_TRIGGERS') const cronSchedulerModule = require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js')
? (require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js')) const cronJitterConfigModule = require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js')
: null const cronGate = require('../tools/ScheduleCronTool/prompt.js') as typeof import('../tools/ScheduleCronTool/prompt.js')
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 extractMemoriesModule = feature('EXTRACT_MEMORIES') const extractMemoriesModule = feature('EXTRACT_MEMORIES')
? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js')) ? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js'))
: null : null
@@ -2701,11 +2695,7 @@ function runHeadlessStreaming(
// the end of run() picks up the queued command. // the end of run() picks up the queued command.
let cronScheduler: import('../utils/cronScheduler.js').CronScheduler | null = let cronScheduler: import('../utils/cronScheduler.js').CronScheduler | null =
null null
if ( if (cronGate.isKairosCronEnabled()) {
feature('AGENT_TRIGGERS') &&
cronSchedulerModule &&
cronGate?.isKairosCronEnabled()
) {
cronScheduler = cronSchedulerModule.createCronScheduler({ cronScheduler = cronSchedulerModule.createCronScheduler({
onFire: prompt => { onFire: prompt => {
if (inputClosed) return if (inputClosed) return
@@ -2727,8 +2717,8 @@ function runHeadlessStreaming(
void run() void run()
}, },
isLoading: () => running || inputClosed, isLoading: () => running || inputClosed,
getJitterConfig: cronJitterConfigModule?.getCronJitterConfig, getJitterConfig: cronJitterConfigModule.getCronJitterConfig,
isKilled: () => !cronGate?.isKairosCronEnabled(), isKilled: () => !cronGate.isKairosCronEnabled(),
}) })
cronScheduler.start() cronScheduler.start()
} }

View File

@@ -82,9 +82,9 @@ export const IN_PROCESS_TEAMMATE_ALLOWED_TOOLS = new Set([
SEND_MESSAGE_TOOL_NAME, SEND_MESSAGE_TOOL_NAME,
// Teammate-created crons are tagged with the creating agentId and routed to // Teammate-created crons are tagged with the creating agentId and routed to
// that teammate's pendingUserMessages queue (see useScheduledTasks.ts). // that teammate's pendingUserMessages queue (see useScheduledTasks.ts).
...(feature('AGENT_TRIGGERS') CRON_CREATE_TOOL_NAME,
? [CRON_CREATE_TOOL_NAME, CRON_DELETE_TOOL_NAME, CRON_LIST_TOOL_NAME] CRON_DELETE_TOOL_NAME,
: []), CRON_LIST_TOOL_NAME,
]) ])
/* /*

View File

@@ -196,7 +196,7 @@ const PROACTIVE_NO_OP_SUBSCRIBE = (_cb: () => void) => () => { };
const PROACTIVE_FALSE = () => false; const PROACTIVE_FALSE = () => false;
const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => 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 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 */ /* eslint-enable @typescript-eslint/no-require-imports */
import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'; import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js';
import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js'; import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js';
@@ -4076,21 +4076,13 @@ export function REPL({
}); });
// Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List) // Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List)
if (feature('AGENT_TRIGGERS')) { // and session-only /loop runs.
// Assistant mode bypasses the isLoading gate (the proactive tick → const assistantMode = store.getState().kairosEnabled;
// Sleep → tick loop would otherwise starve the scheduler). useScheduledTasks({
// kairosEnabled is set once in initialState (main.tsx) and never mutated — no isLoading,
// subscription needed. The tengu_kairos_cron runtime gate is checked inside assistantMode,
// useScheduledTasks's effect (not here) since wrapping a hook call in a dynamic setMessages
// 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
});
}
// Note: Permission polling is now handled by useInboxPoller // Note: Permission polling is now handled by useInboxPoller
// - Workers receive permission responses via mailbox messages // - Workers receive permission responses via mailbox messages

View File

@@ -4,6 +4,7 @@ import { registerBatchSkill } from './batch.js'
import { registerClaudeInChromeSkill } from './claudeInChrome.js' import { registerClaudeInChromeSkill } from './claudeInChrome.js'
import { registerDebugSkill } from './debug.js' import { registerDebugSkill } from './debug.js'
import { registerKeybindingsSkill } from './keybindings.js' import { registerKeybindingsSkill } from './keybindings.js'
import { registerLoopSkill } from './loop.js'
import { registerSimplifySkill } from './simplify.js' import { registerSimplifySkill } from './simplify.js'
import { registerUpdateConfigSkill } from './updateConfig.js' import { registerUpdateConfigSkill } from './updateConfig.js'
@@ -34,15 +35,10 @@ export function initBundledSkills(): void {
/* eslint-enable @typescript-eslint/no-require-imports */ /* eslint-enable @typescript-eslint/no-require-imports */
registerHunterSkill() registerHunterSkill()
} }
if (feature('AGENT_TRIGGERS')) { // /loop's isEnabled delegates to isKairosCronEnabled() — registered
/* eslint-disable @typescript-eslint/no-require-imports */ // unconditionally so the static import is bundled; visibility is gated
const { registerLoopSkill } = require('./loop.js') // at runtime by the isEnabled callback.
/* eslint-enable @typescript-eslint/no-require-imports */ registerLoopSkill()
// /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()
}
if (feature('AGENT_TRIGGERS_REMOTE')) { if (feature('AGENT_TRIGGERS_REMOTE')) {
/* eslint-disable @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/no-require-imports */
const { const {

View File

@@ -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 ---')
})

View File

@@ -6,87 +6,218 @@ import {
} from '../../tools/ScheduleCronTool/prompt.js' } from '../../tools/ScheduleCronTool/prompt.js'
import { registerBundledSkill } from '../bundledSkills.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] <prompt> 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. const MAINTENANCE_PROMPT = `Scheduled maintenance loop iteration.
If no interval is specified, defaults to ${DEFAULT_INTERVAL}.
Examples: If .claude/loop.md exists, read it and follow it.
/loop 5m /babysit-prs Otherwise, if ~/.claude/loop.md exists, read it and follow it.
/loop 30m check the deploy Otherwise:
/loop 1h /standup 1 - continue any unfinished work from the conversation
/loop check the deploy (defaults to ${DEFAULT_INTERVAL}) - tend to the current branch's pull request: review comments, failed CI runs, merge conflicts
/loop check the deploy every 20m` - run cleanup passes such as bug hunts or simplification when nothing else is pending
function buildPrompt(args: string): string { Do not start new initiatives outside that scope.
return `# /loop — schedule a recurring prompt Irreversible actions such as pushing or deleting only proceed when they continue something the transcript already authorized.`
Parse the input below into \`[interval] <prompt…>\` 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. function parseTrailingEveryClause(input: string): {
2. **Trailing "every" clause**: otherwise, if the input ends with \`every <N><unit>\` or \`every <N> <unit-word>\` (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. prompt: string
3. **Default**: otherwise, interval is \`${DEFAULT_INTERVAL}\` and the entire input is the prompt. 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] <prompt>\` 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: const bareInterval = parseIntervalToken(trimmed)
- \`5m /babysit-prs\` → interval \`5m\`, prompt \`/babysit-prs\` (rule 1) if (bareInterval) {
- \`check the deploy every 20m\` → interval \`20m\`, prompt \`check the deploy\` (rule 2) return { mode: 'fixed-maintenance', interval: bareInterval }
- \`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
## 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 | return {
|-----------------------|---------------------|------------------------------------------| mode: 'dynamic-prompt',
| \`Nm\` where N ≤ 59 | \`*/N * * * *\` | every N minutes | prompt: trimmed,
| \`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 |
**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: For the recurring scheduled task, use this exact maintenance prompt body:
- \`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.
## 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 { export function registerLoopSkill(): void {
registerBundledSkill({ registerBundledSkill({
name: 'loop', name: 'loop',
description: 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: 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.', '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>', argumentHint: '[interval] [prompt]',
userInvocable: true, userInvocable: true,
isEnabled: isKairosCronEnabled, isEnabled: isKairosCronEnabled,
async getPromptForCommand(args) { async getPromptForCommand(args) {
const trimmed = args.trim() const parsed = parseLoopArgs(args)
if (!trimmed) { const text =
return [{ type: 'text', text: USAGE_MESSAGE }] parsed.mode === 'fixed-prompt' || parsed.mode === 'fixed-maintenance'
} ? buildFixedPrompt(parsed)
return [{ type: 'text', text: buildPrompt(trimmed) }] : buildDynamicPrompt(parsed)
return [{ type: 'text', text }]
}, },
}) })
} }

View File

@@ -26,13 +26,11 @@ const SleepTool =
feature('PROACTIVE') || feature('KAIROS') feature('PROACTIVE') || feature('KAIROS')
? require('./tools/SleepTool/SleepTool.js').SleepTool ? require('./tools/SleepTool/SleepTool.js').SleepTool
: null : null
const cronTools = feature('AGENT_TRIGGERS') const cronTools = [
? [ require('./tools/ScheduleCronTool/CronCreateTool.js').CronCreateTool,
require('./tools/ScheduleCronTool/CronCreateTool.js').CronCreateTool, require('./tools/ScheduleCronTool/CronDeleteTool.js').CronDeleteTool,
require('./tools/ScheduleCronTool/CronDeleteTool.js').CronDeleteTool, require('./tools/ScheduleCronTool/CronListTool.js').CronListTool,
require('./tools/ScheduleCronTool/CronListTool.js').CronListTool, ]
]
: []
const RemoteTriggerTool = feature('AGENT_TRIGGERS_REMOTE') const RemoteTriggerTool = feature('AGENT_TRIGGERS_REMOTE')
? require('./tools/RemoteTriggerTool/RemoteTriggerTool.js').RemoteTriggerTool ? require('./tools/RemoteTriggerTool/RemoteTriggerTool.js').RemoteTriggerTool
: null : null

View File

@@ -9,39 +9,35 @@ export const DEFAULT_MAX_AGE_DAYS =
DEFAULT_CRON_JITTER_CONFIG.recurringMaxAgeMs / (24 * 60 * 60 * 1000) DEFAULT_CRON_JITTER_CONFIG.recurringMaxAgeMs / (24 * 60 * 60 * 1000)
/** /**
* Unified gate for the cron scheduling system. Combines the build-time * Unified gate for the cron scheduling system.
* `feature('AGENT_TRIGGERS')` flag (dead code elimination) with the runtime
* `tengu_kairos_cron` GrowthBook gate on a 5-minute refresh window.
* *
* AGENT_TRIGGERS is independently shippable from KAIROS — the cron module * Open builds (USER_TYPE !== 'ant') enable cron unconditionally — the
* graph (cronScheduler/cronTasks/cronTasksLock/cron.ts + the three tools + * cron tools and /loop skill are registered without the AGENT_TRIGGERS
* /loop skill) has zero imports into src/assistant/ and no feature('KAIROS') * build flag, so this gate is the sole runtime switch. Set the env var
* calls. The REPL.tsx kairosEnabled read is safe: * `CLAUDE_CODE_DISABLE_CRON=1` to turn it off locally.
* kairosEnabled is unconditionally in AppStateStore with default false, so *
* when KAIROS is off the scheduler just gets assistantMode: false. * 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 / * Called from Tool.isEnabled() (lazy, post-init) and inside useEffect /
* imperative setup, never at module scope — so the disk cache has had a * imperative setup, never at module scope — so the disk cache has had a
* chance to populate. * 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. * `CLAUDE_CODE_DISABLE_CRON` is a local override that wins over GB.
*/ */
export function isKairosCronEnabled(): boolean { export function isKairosCronEnabled(): boolean {
return feature('AGENT_TRIGGERS') if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_CRON)) return false
? !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_CRON) &&
getFeatureValue_CACHED_WITH_REFRESH( // OpenClaude open builds do not rely on Anthropic's internal runtime gates.
'tengu_kairos_cron', // Expose cron support by default unless explicitly disabled.
true, if (process.env.USER_TYPE !== 'ant') return true
KAIROS_CRON_REFRESH_MS,
) return getFeatureValue_CACHED_WITH_REFRESH(
: false 'tengu_kairos_cron',
true,
KAIROS_CRON_REFRESH_MS,
)
} }
/** /**