Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 6 additions & 16 deletions src/cli/print.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()
}
Expand Down
6 changes: 3 additions & 3 deletions src/constants/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
])

/*
Expand Down
24 changes: 8 additions & 16 deletions src/screens/REPL.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
14 changes: 5 additions & 9 deletions src/skills/bundled/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 {
Expand Down
125 changes: 125 additions & 0 deletions src/skills/bundled/loop.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
Comment thread
khaledmoayad marked this conversation as resolved.
Resolved

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