From 7429c358e925ee3f3113106055271438836379fe Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Thu, 12 Mar 2026 14:02:34 +1300 Subject: [PATCH 1/3] fix: resolve cursor positioning on narrow terminals and improve Shift+Enter handling Status row text (e.g. "Update available! Run: curl ...") was never truncated on the right side, causing terminal wrapping on narrow windows. This broke the moveUp cursor calculation and left the cursor outside the composer box. Now the right part is clipped to fit the terminal width before gap calculations. Also adds support for xterm modifyOtherKeys level 2 Shift+Enter sequences (ESC[27;modifier;13~) and broadens residual fragment detection so stray CSI codes don't leak into the text buffer. --- src/ui/inputPrompt.ts | 53 +++++++++++++++++++++----- tests/ui/inputPrompt.test.ts | 72 ++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 10 deletions(-) diff --git a/src/ui/inputPrompt.ts b/src/ui/inputPrompt.ts index 06acf39..824d544 100644 --- a/src/ui/inputPrompt.ts +++ b/src/ui/inputPrompt.ts @@ -52,6 +52,14 @@ export function promptNotify(message: string): void { promptEvents.emit('notify', message); } +/** + * Interrupt the active prompt, causing readInstruction to resolve with the given value. + * Used by repeat jobs to inject instructions while the prompt is blocking the loop. + */ +export function promptInterrupt(value: string): void { + promptEvents.emit('interrupt', value); +} + export const PROMPT_PREFIX = `${chalk.gray('›')} `; // Visible length of the prompt prefix (ANSI codes not counted) export const PROMPT_VISIBLE_LENGTH = 2; @@ -63,7 +71,7 @@ export const PROMPT_LINES_BELOW_INPUT = 1; export const PROMPT_BLOCK_LINE_COUNT = PROMPT_LINES_ABOVE_INPUT + 1 + PROMPT_LINES_BELOW_INPUT; export const PROMPT_PLACEHOLDER = 'Plan, search, build anything'; export const PROMPT_INPUT_PREFIX = '❯ '; -export const SHIFT_ENTER_RESIDUAL_PATTERN = /^13;?[234]?\d*[u~]$/; +export const SHIFT_ENTER_RESIDUAL_PATTERN = /^(?:13;?[234]?\d*[u~]|27;[234];13~)$/; export type SlashCommandHint = SlashCommand; @@ -375,6 +383,10 @@ export function isShiftEnterSequence(str: string, key: readline.Key | undefined) if (/^\x1b\[13;[234]\d*[u~]$/.test(seq)) { return true; } + // xterm modifyOtherKeys level 2: ESC[27;modifier;13~ + if (/^\x1b\[27;[234];13~$/.test(seq)) { + return true; + } // Alt+Enter: ESC followed by carriage return if (seq === '\x1b\r' || seq === '\x1b\n') { return true; @@ -391,7 +403,7 @@ export function countResidualModifiedEnterSequences(chunk: string): number { return 0; } - const matches = chunk.match(/13;?[234]?\d*[u~]/g); + const matches = chunk.match(/(?:13;?[234]?\d*[u~]|27;[234];13~)/g); if (!matches || matches.length === 0) { return 0; } @@ -404,7 +416,7 @@ export function countRawModifiedEnterSequences(chunk: string): number { return 0; } - const matches = chunk.match(/\x1b(?:\[13;[234]\d*[u~]|\r|\n)/g); + const matches = chunk.match(/\x1b(?:\[13;[234]\d*[u~]|\[27;[234];13~|\r|\n)/g); return matches?.length ?? 0; } @@ -725,7 +737,7 @@ function splitMultilineSegments(value: string): MultilineSegments { return { segments, separatorLengths }; } -function formatPromptStatusRow( +export function formatPromptStatusRow( statusLine: string | { left: string; right: string } | undefined, width: number ): string { @@ -739,7 +751,14 @@ function formatPromptStatusRow( } const plainLeft = stripAnsiCodes(left); - const plainRight = right ? stripAnsiCodes(right) : ''; + let plainRight = right ? stripAnsiCodes(right) : ''; + + // Truncate right part if it alone exceeds width — prevents terminal wrapping + // which breaks cursor positioning (moveUp assumes exactly 1 status row). + if (plainRight.length > width) { + plainRight = truncatePlainText(plainRight, width); + } + const minGap = plainRight ? 2 : 0; const availableForLeft = Math.max(0, width - plainRight.length - minGap); const clippedLeft = truncatePlainText(plainLeft, availableForLeft); @@ -1532,6 +1551,7 @@ async function promptOnce(options: PromptOnceOptions): Promise { mentionPreview.dispose(); resizeWatcher.dispose(); promptEvents.off('notify', onPromptNotify); + promptEvents.off('interrupt', onPromptInterrupt); input.off('keypress', handleKeypress); input.off('data', handleInputData); if (originalRefreshLine) { @@ -1562,6 +1582,20 @@ async function promptOnce(options: PromptOnceOptions): Promise { const onPromptNotify = (msg: string) => showPromptMessage(msg); promptEvents.on('notify', onPromptNotify); + // Subscribe to external interrupts (e.g. repeat job triggers). + // Mirrors the normal rl 'line' submit path so terminal state is clean. + const onPromptInterrupt = (value: string) => { + if (closed) return; + mentionPreview.reset(); + if (contextualHelpVisible) { + setContextualHelpVisible(false); + } + leavePromptSurface(stdOutput, STATUS_LINE_COUNT); + cleanup(); + resolve({ kind: 'submit', value }); + }; + promptEvents.on('interrupt', onPromptInterrupt); + const refreshLine = () => { renderActivePrompt(); }; @@ -1602,10 +1636,9 @@ async function promptOnce(options: PromptOnceOptions): Promise { if (pasteState.isInPaste) { return; } - // Catch residual CSI u fragments that readline passes as literal text. - // These never fire keypress events, so TextBuffer can't handle them. - // Insert a real newline into the TextBuffer. - if (/^13;?[234]?\d*[u~]$/.test(s)) { + // Catch residual CSI u / modifyOtherKeys fragments that readline + // passes as literal text. Insert a real newline into the TextBuffer. + if (/^(?:13;?[234]?\d*[u~]|27;[234];13~)$/.test(s)) { textBuffer.insert('\n'); syncReadlineFromBuffer(); renderActivePrompt(); @@ -1768,7 +1801,7 @@ async function promptOnce(options: PromptOnceOptions): Promise { if ( isShiftEnterSequence(_str, key) || isShiftEnterResidualSequence(rawSeq) || - (_str.length > 0 && /^[13;~u]+$/.test(_str)) + (_str.length > 0 && /^[\d;~u]+$/.test(_str)) ) { return; } diff --git a/tests/ui/inputPrompt.test.ts b/tests/ui/inputPrompt.test.ts index 93365f2..a1e4e14 100644 --- a/tests/ui/inputPrompt.test.ts +++ b/tests/ui/inputPrompt.test.ts @@ -582,6 +582,36 @@ describe('partial escape sequence filtering', () => { expect(countResidualModifiedEnterSequences('13;2u13;2u')).toBe(2); expect(countResidualModifiedEnterSequences('hello13~')).toBe(0); }); + + // ─── BUG 3: xterm modifyOtherKeys format ────────────────────────────── + + it('detects xterm modifyOtherKeys Shift+Enter: ESC[27;2;13~', async () => { + const { isShiftEnterSequence } = await import('../../src/ui/inputPrompt.js'); + + // xterm modifyOtherKeys level 2: ESC[27;modifier;keycode~ + // modifier 2=Shift, 3=Alt, 4=Shift+Alt + expect(isShiftEnterSequence('\x1b[27;2;13~', { sequence: '\x1b[27;2;13~' } as readline.Key)).toBe(true); + expect(isShiftEnterSequence('\x1b[27;3;13~', { sequence: '\x1b[27;3;13~' } as readline.Key)).toBe(true); + expect(isShiftEnterSequence('\x1b[27;4;13~', { sequence: '\x1b[27;4;13~' } as readline.Key)).toBe(true); + }); + + it('counts raw xterm modifyOtherKeys Shift+Enter sequences', async () => { + const { countRawModifiedEnterSequences } = await import('../../src/ui/inputPrompt.js'); + + expect(countRawModifiedEnterSequences('\x1b[27;2;13~')).toBe(1); + expect(countRawModifiedEnterSequences('\x1b[27;3;13~')).toBe(1); + }); + + it('detects residual xterm modifyOtherKeys fragments', async () => { + const { isShiftEnterResidualSequence } = await import('../../src/ui/inputPrompt.js'); + + // After readline strips ESC[, residual would be "27;2;13~" + expect(isShiftEnterResidualSequence('27;2;13~')).toBe(true); + expect(isShiftEnterResidualSequence('27;3;13~')).toBe(true); + expect(isShiftEnterResidualSequence('27;4;13~')).toBe(true); + // Should NOT match non-Enter keycodes + expect(isShiftEnterResidualSequence('27;2;9~')).toBe(false); + }); }); describe('buildMultiLineRenderState', () => { @@ -883,3 +913,45 @@ describe('TextBuffer integration into inputPrompt', () => { expect(state.lineCount).toBe(2); }); }); + +describe('formatPromptStatusRow', () => { + const stripAnsi = (s: string) => s.replace(/\u001b\[[0-9;]*[A-Za-z]/g, ''); + + it('truncates right part when it exceeds width on narrow terminals', async () => { + const { formatPromptStatusRow } = await import('../../src/ui/inputPrompt.js'); + + // Simulate a narrow terminal (width 60) with a long "Update available!" right side + const longRight = 'Update available! Run: curl -fsSL https://autohand.ai/install.sh | sh'; + const statusLine = { left: '93% context left', right: longRight }; + const width = 60; + + const row = formatPromptStatusRow(statusLine, width); + const plainRow = stripAnsi(row); + + // The visible row must NEVER exceed the given width — overflow causes + // terminal wrapping which breaks cursor positioning + expect(plainRow.length).toBeLessThanOrEqual(width); + }); + + it('status row fits width when right part is shorter than width', async () => { + const { formatPromptStatusRow } = await import('../../src/ui/inputPrompt.js'); + + const statusLine = { left: '93% context left', right: 'v1.2.3' }; + const width = 80; + + const row = formatPromptStatusRow(statusLine, width); + const plainRow = stripAnsi(row); + + expect(plainRow.length).toBeLessThanOrEqual(width); + expect(plainRow).toContain('v1.2.3'); + }); + + it('status row handles string-only status line', async () => { + const { formatPromptStatusRow } = await import('../../src/ui/inputPrompt.js'); + + const row = formatPromptStatusRow('plan:off · 93% context left', 60); + const plainRow = stripAnsi(row); + + expect(plainRow.length).toBeLessThanOrEqual(60); + }); +}); From 0c71d533a48d65093e22f6ee402c7d5f54ba5aeb Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Thu, 12 Mar 2026 14:02:47 +1300 Subject: [PATCH 2/3] feat: add /repeat slash command for recurring prompt scheduling Introduces a new /repeat command that lets users schedule prompts to run on a recurring cadence. The command uses LLM-first parsing with regex fallback to extract intervals from natural language input. Key features: - RepeatManager with in-process setInterval scheduling and auto-expiry - LLM parses schedule, prompt, maxRuns, and custom expiry from input - maxRuns support: auto-cancels after N executions (e.g. "only 10 times") - Custom expiry: override default 3-day limit (e.g. "for the next week") - Disambiguation rule: when conflicting intervals exist, the most specific one wins and the broader becomes expiry context - /repeat list and /repeat cancel subcommands - promptInterrupt mechanism for injecting repeat prompts into the idle input loop without polling --- src/commands/repeat.ts | 694 ++++++++++++++++++++++++++++++++ src/core/RepeatManager.ts | 136 +++++++ src/core/agent.ts | 23 ++ src/core/slashCommandHandler.ts | 4 + src/core/slashCommandTypes.ts | 3 + src/core/slashCommands.ts | 2 + tests/commands/repeat.test.ts | 670 ++++++++++++++++++++++++++++++ 7 files changed, 1532 insertions(+) create mode 100644 src/commands/repeat.ts create mode 100644 src/core/RepeatManager.ts create mode 100644 tests/commands/repeat.test.ts diff --git a/src/commands/repeat.ts b/src/commands/repeat.ts new file mode 100644 index 0000000..d57d57c --- /dev/null +++ b/src/commands/repeat.ts @@ -0,0 +1,694 @@ +/** + * @license + * Copyright 2025 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + * + * /repeat — schedule a recurring prompt at a fixed interval. + * + * Usage: + * /repeat [interval] + * /repeat cancel + * /repeat list + */ +import chalk from 'chalk'; +import type { SlashCommand } from '../core/slashCommandTypes.js'; +import type { RepeatManager } from '../core/RepeatManager.js'; +import type { LLMProvider } from '../providers/LLMProvider.js'; + +export const metadata: SlashCommand = { + command: '/repeat', + description: 'Schedule a recurring prompt at a fixed interval', + implemented: true, +}; + +export interface RepeatCommandContext { + repeatManager?: RepeatManager; + llm?: LLMProvider; +} + +const DEFAULT_INTERVAL = '5m'; + +// ─── Parsing ──────────────────────────────────────────────────────────────── + +interface ParsedInput { + interval: string; + prompt: string; + /** True when the interval was explicitly matched by a regex rule (not the default fallback) */ + explicit?: boolean; + /** Maximum number of executions before auto-cancel. */ + maxRuns?: number; + /** Custom expiry duration in shorthand (e.g. "7d", "2h"). */ + expiresIn?: string; +} + +const LEADING_INTERVAL_RE = /^\d+[smhd]$/; +const LEADING_EVERY_RE = /^every\s+(\d+)?\s*(s(?:ec(?:ond)?s?)?|m(?:in(?:ute)?s?)?|h(?:(?:ou)?rs?)?|d(?:ays?)?)\s+/i; +const TRAILING_EVERY_RE = /\s+every\s+(\d+)?\s*(s(?:ec(?:ond)?s?)?|m(?:in(?:ute)?s?)?|h(?:(?:ou)?rs?)?|d(?:ays?)?)$/i; +const MIDDLE_EVERY_RE = /\s+every\s+(\d+)?\s*(s(?:ec(?:ond)?s?)?|m(?:in(?:ute)?s?)?|h(?:(?:ou)?rs?)?|d(?:ays?)?)\s+/i; +const UNIT_MAP: Record = { + s: 's', sec: 's', secs: 's', second: 's', seconds: 's', + m: 'm', min: 'm', mins: 'm', minute: 'm', minutes: 'm', + h: 'h', hr: 'h', hrs: 'h', hour: 'h', hours: 'h', + d: 'd', day: 'd', days: 'd', +}; + +/** + * Parse raw user input into { interval, prompt } following the priority rules: + * 1. Leading token matching ^\d+[smhd]$ + * 2. Leading "every " clause (e.g. "every 2 minutes run tests") + * 3. Trailing "every " clause (e.g. "run tests every 5m") + * 4. Middle "every " clause (e.g. "tell a joke every 10s about life") + * 5. Default interval with entire input as prompt + */ +export function parseInput(raw: string): ParsedInput { + const trimmed = raw.trim(); + const parts = trimmed.split(/\s+/); + + // Rule 1: leading interval token (e.g. "5m run tests") + if (parts.length >= 1 && LEADING_INTERVAL_RE.test(parts[0])) { + return { + interval: parts[0], + prompt: parts.slice(1).join(' '), + explicit: true, + }; + } + + // Rule 2: leading "every [N] " (e.g. "every 2 minutes run tests", "every minute check logs") + const leadingMatch = trimmed.match(LEADING_EVERY_RE); + if (leadingMatch) { + const n = leadingMatch[1] || '1'; + const rawUnit = leadingMatch[2].toLowerCase(); + const unit = UNIT_MAP[rawUnit]; + if (unit) { + return { + interval: `${n}${unit}`, + prompt: trimmed.slice(leadingMatch[0].length).trim(), + explicit: true, + }; + } + } + + // Rule 3: trailing "every [N] " (e.g. "run tests every 5m", "say hello every minute") + const trailingMatch = trimmed.match(TRAILING_EVERY_RE); + if (trailingMatch) { + const n = trailingMatch[1] || '1'; + const rawUnit = trailingMatch[2].toLowerCase(); + const unit = UNIT_MAP[rawUnit]; + if (unit) { + return { + interval: `${n}${unit}`, + prompt: trimmed.slice(0, trailingMatch.index!).trim(), + explicit: true, + }; + } + } + + // Rule 4: middle "every [N] " (e.g. "tell a joke every 10s about life", "say hello every minute to me") + const middleMatch = trimmed.match(MIDDLE_EVERY_RE); + if (middleMatch) { + const n = middleMatch[1] || '1'; + const rawUnit = middleMatch[2].toLowerCase(); + const unit = UNIT_MAP[rawUnit]; + if (unit) { + const before = trimmed.slice(0, middleMatch.index!).trim(); + const after = trimmed.slice(middleMatch.index! + middleMatch[0].length).trim(); + const prompt = [before, after].filter(Boolean).join(' '); + return { + interval: `${n}${unit}`, + prompt, + explicit: true, + }; + } + } + + // Rule 5: default — no interval detected + return { + interval: DEFAULT_INTERVAL, + prompt: trimmed, + explicit: false, + }; +} + +// ─── Interval → cron ──────────────────────────────────────────────────────── + +interface CronResult { + cronExpression: string; + intervalMs: number; + humanReadable: string; + roundedNote?: string; +} + +/** + * Convert a shorthand interval (e.g. "5m", "2h", "1d", "30s") to a cron expression. + * Returns the cron expression, the interval in ms, a human-readable cadence, + * and an optional note if the interval was rounded. + */ +export function intervalToCron(interval: string): CronResult { + const match = interval.match(/^(\d+)([smhd])$/); + if (!match) { + throw new Error(`Invalid interval format: ${interval}`); + } + + let n = parseInt(match[1], 10); + const unit = match[2]; + + // Seconds → round up to nearest minute (min 1m) + if (unit === 's') { + const minutes = Math.max(1, Math.ceil(n / 60)); + const roundedNote = `Rounded ${n}s up to ${minutes}m (cron minimum granularity is 1 minute).`; + return intervalToCronMinutes(minutes, roundedNote); + } + + if (unit === 'm') { + return intervalToCronMinutes(n); + } + + if (unit === 'h') { + if (n <= 0) throw new Error('Hour interval must be >= 1'); + if (n > 23) { + // Convert to days + const days = Math.round(n / 24); + const roundedNote = `Rounded ${n}h to ${days}d (${days * 24}h).`; + return { + cronExpression: `0 0 */${days} * *`, + intervalMs: days * 24 * 60 * 60 * 1000, + humanReadable: `every ${days} day${days > 1 ? 's' : ''} at midnight`, + roundedNote, + }; + } + // Check if it divides 24 cleanly + let roundedNote: string | undefined; + if (24 % n !== 0) { + const clean = nearestDivisor(n, 24); + roundedNote = `${n}h doesn't divide 24 evenly; rounded to every ${clean}h.`; + n = clean; + } + return { + cronExpression: `0 */${n} * * *`, + intervalMs: n * 60 * 60 * 1000, + humanReadable: `every ${n} hour${n > 1 ? 's' : ''}`, + roundedNote, + }; + } + + // Days + if (n <= 0) throw new Error('Day interval must be >= 1'); + return { + cronExpression: `0 0 */${n} * *`, + intervalMs: n * 24 * 60 * 60 * 1000, + humanReadable: `every ${n} day${n > 1 ? 's' : ''} at midnight`, + }; +} + +function intervalToCronMinutes(n: number, roundedNote?: string): CronResult { + if (n <= 0) throw new Error('Minute interval must be >= 1'); + + if (n < 60) { + // Check if it divides 60 cleanly + let note = roundedNote; + if (60 % n !== 0) { + const clean = nearestDivisor(n, 60); + note = (note ? note + ' ' : '') + `${n}m doesn't divide 60 evenly; rounded to every ${clean}m.`; + n = clean; + } + return { + cronExpression: `*/${n} * * * *`, + intervalMs: n * 60 * 1000, + humanReadable: `every ${n} minute${n > 1 ? 's' : ''}`, + roundedNote: note, + }; + } + + // 60+ minutes → convert to hours + let hours = Math.round(n / 60); + if (hours <= 0) hours = 1; + let note = roundedNote; + if (24 % hours !== 0) { + const clean = nearestDivisor(hours, 24); + note = (note ? note + ' ' : '') + `${n}m rounds to ${hours}h which doesn't divide 24; using every ${clean}h.`; + hours = clean; + } else if (n !== hours * 60) { + note = (note ? note + ' ' : '') + `Rounded ${n}m to ${hours}h.`; + } + return { + cronExpression: `0 */${hours} * * *`, + intervalMs: hours * 60 * 60 * 1000, + humanReadable: `every ${hours} hour${hours > 1 ? 's' : ''}`, + roundedNote: note, + }; +} + +/** + * Find the divisor of `max` closest to `n`. + */ +function nearestDivisor(n: number, max: number): number { + let best = 1; + for (let d = 1; d <= max; d++) { + if (max % d === 0 && Math.abs(d - n) < Math.abs(best - n)) { + best = d; + } + } + return best; +} + +// ─── Duration helpers ──────────────────────────────────────────────────────── + +/** + * Convert shorthand duration (e.g. "7d", "2h") to milliseconds. + */ +function shorthandToMs(shorthand: string): number { + const match = shorthand.match(/^(\d+)([smhd])$/); + if (!match) return 3 * 24 * 60 * 60 * 1000; // fallback 3 days + const n = parseInt(match[1], 10); + const unit = match[2]; + switch (unit) { + case 's': return n * 1000; + case 'm': return n * 60 * 1000; + case 'h': return n * 60 * 60 * 1000; + case 'd': return n * 24 * 60 * 60 * 1000; + default: return 3 * 24 * 60 * 60 * 1000; + } +} + +/** + * Convert shorthand duration to human-readable string. + */ +function shorthandToHuman(shorthand: string): string { + const match = shorthand.match(/^(\d+)([smhd])$/); + if (!match) return shorthand; + const n = parseInt(match[1], 10); + const unit = match[2]; + const names: Record = { s: 'second', m: 'minute', h: 'hour', d: 'day' }; + const name = names[unit] ?? unit; + return `${n} ${name}${n !== 1 ? 's' : ''}`; +} + +// ─── LLM intent extraction ────────────────────────────────────────────────── + +const REPEAT_SYSTEM_PROMPT = `# Goal +Extract a recurring schedule from natural language. Return the interval and the actionable task as JSON. + +# Persona +You are a scheduling intent parser for a CLI coding agent. You receive raw user input and decompose it into exactly two parts: how often (interval) and what to do (prompt). You are precise, never hallucinate fields, and always return valid JSON. + +# Action +Return ONLY a JSON object with these fields: +- "interval": (required) shorthand format where unit is s, m, h, or d (e.g. "5m", "2h", "1d") +- "prompt": (required) the actionable task, cleaned of all scheduling language and conversational filler +- "maxRuns": (optional, integer) if the user specifies a finite execution count, include it. Omit if unlimited. +- "expiresIn": (optional, shorthand like interval) if the user specifies a custom duration/deadline, include it. Omit to use the default 3-day expiry. + +## Interval extraction rules (in priority order) + +1. Explicit frequency → convert directly: + "every 5 minutes" → "5m" | "every 2 hours" → "2h" | "every 30 seconds" → "30s" | "every 3 days" → "3d" + +2. Named frequencies → map to interval: + "every minute" → "1m" | "every hour" → "1h" | "every day" → "1d" | "every second" → "1s" + "hourly" → "1h" | "daily" → "1d" | "every half hour" → "30m" | "every quarter hour" → "15m" + +3. Multiplicative expressions → compute interval: + "twice an hour" → "30m" | "three times an hour" → "20m" | "once an hour" → "1h" + "twice a day" → "12h" | "three times a day" → "8h" | "once a day" → "1d" + +4. Colloquial / vague frequencies → best-effort: + "every couple of minutes" → "2m" | "every few minutes" → "5m" | "every other hour" → "2h" + "continuously" / "nonstop" / "constantly" → "1m" | "periodically" / "frequently" / "regularly" → "5m" + +5. Implied repetition words are scheduling intent — strip them from the prompt: + "keep running tests" → interval "1m", prompt "run tests" + "keep checking the build" → interval "1m", prompt "check the build" + "repeatedly lint the codebase" → interval "5m", prompt "lint the codebase" + +6. Unsupported time scales → clamp to "1d": + "weekly" / "monthly" / "every week" / "every month" → "1d" + +7. No frequency detected at all → use "5m" as default. + +## Disambiguation: conflicting interval signals + +When the input contains multiple time references that could be intervals, apply these rules: +1. The most specific/granular interval wins as the "interval" field. +2. The broader time reference becomes "expiresIn" context (if it specifies a duration). + +Granularity order (most specific first): seconds → minutes → hours → days. + +Examples: +- "every day run this command with intervals of 10 minutes per run" → interval: "10m", expiresIn: "1d" + ("intervals of 10 minutes" is more specific than "every day") +- "check deploy every day for a week at 10 minute intervals" → interval: "10m", expiresIn: "7d" +- "run tests daily with 5 minute checks" → interval: "5m", expiresIn: "1d" +- "every hour check build every 2 minutes" → interval: "2m", expiresIn: "1h" + +## Execution count extraction (maxRuns) + +When the user specifies a finite number of executions, extract it as "maxRuns": +- "only 10 times" → maxRuns: 10 +- "do this 5 times" → maxRuns: 5 +- "repeat 3 times" → maxRuns: 3 +- "just once" / "one time" → maxRuns: 1 +- "twice" / "two times" → maxRuns: 2 +- No count mentioned → omit maxRuns entirely + +Strip the count language from the prompt (e.g. "only 10 times", "5 times", "twice"). + +## Custom expiry extraction (expiresIn) + +When the user specifies a duration/deadline for how long the job should stay active, extract it as "expiresIn": +- "for the next week" / "for a week" → expiresIn: "7d" +- "for 2 days" → expiresIn: "2d" +- "for the next hour" → expiresIn: "1h" +- "until tomorrow" → expiresIn: "1d" +- "for 30 minutes" → expiresIn: "30m" +- No duration mentioned → omit expiresIn entirely (default: 3 days) + +Strip the expiry language from the prompt. + +## Prompt cleaning rules + +1. Strip conversational filler: remove leading "can you", "could you", "please", "I want you to", "I need you to", "go ahead and", "make sure to", "try to". +2. Strip scheduling language: remove "every X minutes", "hourly", "daily", "continuously", "keep", "repeatedly", "nonstop", "on repeat", "periodically", etc. +3. Preserve slash commands and their arguments verbatim: "/deploy staging", "/babysit-prs", "/lint --fix". +4. Preserve technical content exactly: file paths, command flags, branch names, URLs. +5. Do NOT rephrase or summarize the task — keep the user's own words minus filler and scheduling. + +## Disambiguation: "every" as quantifier vs frequency + +"every" followed by a time expression (number + unit, or time noun) = frequency → extract it. +"every" followed by a non-time noun = quantifier (part of the task) → keep it in the prompt. + +Frequency: "check deploy every 5 minutes", "run hourly", "every 2h run tests" +Quantifier: "check every PR", "review every file", "test every endpoint", "lint every module" + +## Disambiguation: time words inside task names + +If a time word (hourly, daily, etc.) is part of a compound noun or script name, it describes the TASK, not the frequency. +"run the hourly backup script" → interval "5m", prompt "run the hourly backup script" +"check the daily report" → interval "5m", prompt "check the daily report" +"execute the 5-minute health check" → interval "5m", prompt "execute the 5-minute health check" + +# Examples + +Input: "can you run tests every 5 minutes" +Output: {"interval":"5m","prompt":"run tests"} + +Input: "please check the deploy hourly" +Output: {"interval":"1h","prompt":"check the deploy"} + +Input: "navigate to my gh pr list and pick one item to work on every 2 minutes" +Output: {"interval":"2m","prompt":"navigate to my gh pr list and pick one item to work on"} + +Input: "make a git commit with empty message to trigger ci cd hourly" +Output: {"interval":"1h","prompt":"make a git commit with empty message to trigger ci cd"} + +Input: "check for new issues on github twice an hour" +Output: {"interval":"30m","prompt":"check for new issues on github"} + +Input: "run the test suite" +Output: {"interval":"5m","prompt":"run the test suite"} + +Input: "keep monitoring the logs" +Output: {"interval":"1m","prompt":"monitor the logs"} + +Input: "check every PR for review comments" +Output: {"interval":"5m","prompt":"check every PR for review comments"} + +Input: "I want you to continuously watch the build pipeline" +Output: {"interval":"1m","prompt":"watch the build pipeline"} + +Input: "run the hourly backup script every 30m" +Output: {"interval":"30m","prompt":"run the hourly backup script"} + +Input: "/deploy staging every 10m" +Output: {"interval":"10m","prompt":"/deploy staging"} + +Input: "could you maybe check if the tests pass" +Output: {"interval":"5m","prompt":"check if the tests pass"} + +Input: "check for git commits from origin every minute" +Output: {"interval":"1m","prompt":"check for git commits from origin"} + +Input: "tell me a joke every 2 minutes only 10 times" +Output: {"interval":"2m","prompt":"tell me a joke","maxRuns":10} + +Input: "check PRs every day for the next week" +Output: {"interval":"1d","prompt":"check PRs","expiresIn":"7d"} + +Input: "run tests every 5m do this 3 times" +Output: {"interval":"5m","prompt":"run tests","maxRuns":3} + +Input: "check build every hour only 5 times for 2 days" +Output: {"interval":"1h","prompt":"check build","maxRuns":5,"expiresIn":"2d"} + +Input: "every day run echo hello world with intervals of 10 minutes per run" +Output: {"interval":"10m","prompt":"run echo hello world","expiresIn":"1d"} + +Input: "every hour check build every 2 minutes" +Output: {"interval":"2m","prompt":"check build","expiresIn":"1h"}`; + +/** + * Use the LLM to extract interval + prompt from ambiguous natural language. + * Falls back to the regex result on any error. + */ +async function llmParseInput(raw: string, llm: LLMProvider): Promise { + try { + const response = await llm.complete({ + messages: [ + { role: 'system', content: REPEAT_SYSTEM_PROMPT }, + { role: 'user', content: raw }, + ], + maxTokens: 200, + temperature: 0, + }); + + const text = response.content.trim(); + // Extract JSON from response (may have markdown fences) + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) return parseInput(raw); + + const parsed = JSON.parse(jsonMatch[0]) as Record; + const interval = typeof parsed.interval === 'string' ? parsed.interval : DEFAULT_INTERVAL; + const prompt = typeof parsed.prompt === 'string' ? parsed.prompt : ''; + + // Validate the interval format + if (!/^\d+[smhd]$/.test(interval)) return parseInput(raw); + + // Extract optional maxRuns + const maxRuns = typeof parsed.maxRuns === 'number' && Number.isInteger(parsed.maxRuns) && parsed.maxRuns > 0 + ? parsed.maxRuns + : undefined; + + // Extract optional expiresIn + const expiresIn = typeof parsed.expiresIn === 'string' && /^\d+[smhd]$/.test(parsed.expiresIn) + ? parsed.expiresIn + : undefined; + + return { interval, prompt, maxRuns, expiresIn }; + } catch { + // On any LLM error, fall back to regex parsing + return parseInput(raw); + } +} + +// ─── Command handler ───────────────────────────────────────────────────────── + +export async function repeat( + ctx: RepeatCommandContext, + args: string[] = [], +): Promise { + if (!ctx.repeatManager) { + return chalk.yellow('Repeat manager not available.'); + } + + const raw = args.join(' ').trim(); + + // Subcommand: help + if (args[0]?.toLowerCase() === 'help') { + return showUsage(); + } + + // Subcommand: cancel + if (args[0]?.toLowerCase() === 'cancel') { + const id = args[1]?.trim(); + if (!id) { + return chalk.yellow('Usage: /repeat cancel '); + } + const ok = ctx.repeatManager.cancel(id); + return ok + ? chalk.green(`Cancelled recurring job ${chalk.bold(id)}.`) + : chalk.yellow(`No active job with ID ${chalk.bold(id)}.`); + } + + // Subcommand: list + if (args[0]?.toLowerCase() === 'list') { + const jobs = ctx.repeatManager.list(); + if (jobs.length === 0) { + return chalk.gray('No recurring jobs scheduled.'); + } + const lines = [chalk.bold('Active recurring jobs:'), '']; + for (const job of jobs) { + const remaining = Math.max(0, job.expiresAt - Date.now()); + const hoursLeft = Math.round(remaining / (60 * 60 * 1000)); + lines.push( + ` ${chalk.cyan(job.id)} ${chalk.white(job.humanInterval)} ${chalk.gray(job.cronExpression)}`, + ` ${chalk.gray('prompt:')} ${job.prompt}`, + ); + if (job.maxRuns !== undefined) { + lines.push(` ${chalk.gray(`runs: ${job.runCount}/${job.maxRuns}`)}`); + } + lines.push( + ` ${chalk.gray(`expires in ~${hoursLeft}h`)}`, + '', + ); + } + return lines.join('\n'); + } + + // Subcommand: help or no args + if (!raw) { + return showUsage(); + } + + // Typo detection: if the user typed a single word that looks like a + // misspelled subcommand, suggest the correct one instead of scheduling. + if (args.length === 1) { + const SUBCOMMANDS = ['list', 'cancel', 'help']; + const firstArg = args[0].toLowerCase(); + if (!SUBCOMMANDS.includes(firstArg)) { + const closest = findClosestSubcommand(firstArg, SUBCOMMANDS); + if (closest) { + return chalk.yellow(`Unknown subcommand "${args[0]}". Did you mean ${chalk.bold(`/repeat ${closest}`)}?`) + '\n\n' + showUsage(); + } + } + } + + // LLM-first parsing: try LLM for best natural language understanding, + // fall back to regex if LLM is unavailable or fails + let interval: string; + let prompt: string; + let maxRuns: number | undefined; + let expiresIn: string | undefined; + + if (ctx.llm) { + console.log(chalk.gray('Parsing schedule...')); + const llmResult = await llmParseInput(raw, ctx.llm); + interval = llmResult.interval; + prompt = llmResult.prompt; + maxRuns = llmResult.maxRuns; + expiresIn = llmResult.expiresIn; + } else { + const regexResult = parseInput(raw); + interval = regexResult.interval; + prompt = regexResult.prompt; + } + + if (!prompt) { + return showUsage(); + } + + // Convert interval to cron + let cron: CronResult; + try { + cron = intervalToCron(interval); + } catch (err) { + return chalk.red(`Invalid interval "${interval}": ${(err as Error).message}`); + } + + // Compute custom expiry duration + const expiresInMs = expiresIn ? shorthandToMs(expiresIn) : undefined; + const expiresInHuman = expiresIn ? shorthandToHuman(expiresIn) : undefined; + + // Schedule the job + const job = ctx.repeatManager.schedule(prompt, cron.intervalMs, cron.cronExpression, cron.humanReadable, { + maxRuns, + expiresInMs, + }); + + // Confirmation message + const lines = [ + chalk.green('Recurring job scheduled!'), + '', + ` ${chalk.gray('Job ID:')} ${chalk.cyan(job.id)}`, + ` ${chalk.gray('Prompt:')} ${prompt}`, + ` ${chalk.gray('Cadence:')} ${cron.humanReadable}`, + ` ${chalk.gray('Cron:')} ${cron.cronExpression}`, + ]; + + if (maxRuns !== undefined) { + lines.push(` ${chalk.gray('Limit:')} ${maxRuns} runs`); + } + + if (cron.roundedNote) { + lines.push(` ${chalk.yellow('Note:')} ${cron.roundedNote}`); + } + + const expiryLabel = expiresInHuman ?? '3 days'; + lines.push( + '', + chalk.gray(`Recurring jobs auto-expire after ${expiryLabel}.`), + chalk.gray(`Cancel sooner with: /repeat cancel ${job.id}`), + '', + ); + + return lines.join('\n'); +} + +/** + * Compute Levenshtein edit distance between two strings. + */ +function levenshtein(a: string, b: string): number { + const m = a.length, n = b.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); + } + } + return dp[m][n]; +} + +/** + * Find the closest subcommand within edit distance ≤ 2. + * Only triggers for single-word inputs that look like typos (short, no spaces). + */ +function findClosestSubcommand(input: string, subcommands: string[]): string | null { + let best: string | null = null; + let bestDist = 3; // threshold: must be ≤ 2 + for (const cmd of subcommands) { + const dist = levenshtein(input, cmd); + if (dist < bestDist) { + bestDist = dist; + best = cmd; + } + } + return best; +} + +function showUsage(): string { + return ` +${chalk.cyan('/repeat — Schedule a recurring prompt')} + +${chalk.yellow('Usage:')} + /repeat [interval] Schedule a recurring prompt + /repeat list Show active jobs + /repeat cancel Cancel a job + +${chalk.yellow('Interval formats:')} + 5m every 5 minutes + 2h every 2 hours + 1d every day + 30s every 30 seconds (rounded to 1m) + +${chalk.yellow('Examples:')} + /repeat 5m run tests + /repeat check the deploy every 20m + /repeat run tests every 5 minutes + /repeat 2h check build status + /repeat list + /repeat cancel abc123 +`; +} diff --git a/src/core/RepeatManager.ts b/src/core/RepeatManager.ts new file mode 100644 index 0000000..37d4a4e --- /dev/null +++ b/src/core/RepeatManager.ts @@ -0,0 +1,136 @@ +/** + * @license + * Copyright 2025 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + * + * RepeatManager — in-process scheduler for recurring prompts. + * Jobs run at a fixed interval using setInterval and auto-expire after 3 days. + */ + +import { randomUUID } from 'node:crypto'; + +export interface RepeatJob { + id: string; + prompt: string; + intervalMs: number; + cronExpression: string; + humanInterval: string; + createdAt: number; + expiresAt: number; + /** Maximum number of executions before auto-cancel. Undefined = unlimited. */ + maxRuns?: number; + /** Number of times the job has triggered so far. */ + runCount: number; +} + +export interface ScheduleOptions { + /** Auto-cancel after this many executions. */ + maxRuns?: number; + /** Custom expiry duration in ms (overrides the default 3 days). */ + expiresInMs?: number; +} + +export type RepeatJobCallback = (job: RepeatJob) => void; + +const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000; + +export class RepeatManager { + private jobs = new Map(); + private timers = new Map>(); + private expiryTimers = new Map>(); + private callback: RepeatJobCallback | null = null; + + /** + * Register the callback that fires each time a job triggers. + */ + onTrigger(cb: RepeatJobCallback): void { + this.callback = cb; + } + + /** + * Schedule a new recurring job. + * Returns the created RepeatJob. + */ + schedule(prompt: string, intervalMs: number, cronExpression: string, humanInterval: string, options?: ScheduleOptions): RepeatJob { + const id = randomUUID().slice(0, 8); + const now = Date.now(); + const expiresInMs = options?.expiresInMs ?? THREE_DAYS_MS; + const job: RepeatJob = { + id, + prompt, + intervalMs, + cronExpression, + humanInterval, + createdAt: now, + expiresAt: now + expiresInMs, + maxRuns: options?.maxRuns, + runCount: 0, + }; + + this.jobs.set(id, job); + + // Set up the recurring interval + const timer = setInterval(() => { + job.runCount++; + if (this.callback) { + this.callback(job); + } + // Auto-cancel when maxRuns limit reached + if (job.maxRuns !== undefined && job.runCount >= job.maxRuns) { + this.cancel(id); + } + }, intervalMs); + + // Prevent the interval from keeping the process alive + if (timer.unref) timer.unref(); + + this.timers.set(id, timer); + + // Set up auto-expiry + const expiryTimer = setTimeout(() => { + this.cancel(id); + }, expiresInMs); + + if (expiryTimer.unref) expiryTimer.unref(); + + this.expiryTimers.set(id, expiryTimer); + + return job; + } + + /** + * Cancel a scheduled job by ID. + * Returns true if the job was found and cancelled. + */ + cancel(id: string): boolean { + const timer = this.timers.get(id); + if (timer) { + clearInterval(timer); + this.timers.delete(id); + } + + const expiryTimer = this.expiryTimers.get(id); + if (expiryTimer) { + clearTimeout(expiryTimer); + this.expiryTimers.delete(id); + } + + return this.jobs.delete(id); + } + + /** + * List all active jobs. + */ + list(): RepeatJob[] { + return [...this.jobs.values()]; + } + + /** + * Cancel all jobs and clean up. + */ + shutdown(): void { + for (const id of this.jobs.keys()) { + this.cancel(id); + } + } +} diff --git a/src/core/agent.ts b/src/core/agent.ts index cae9fc1..a5d237c 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -20,6 +20,7 @@ import type { LLMProvider } from '../providers/LLMProvider.js'; import { ProviderNotConfiguredError } from '../providers/ProviderFactory.js'; import { getPromptBlockWidth, + promptInterrupt, promptNotify, readInstruction, safeEmitKeypressEvents @@ -84,6 +85,7 @@ type InkRenderer = any; import { PermissionManager } from '../permissions/PermissionManager.js'; import { HookManager } from './HookManager.js'; import { TeamManager } from './teams/TeamManager.js'; +import { RepeatManager } from './RepeatManager.js'; import { confirm as unifiedConfirm, isExternalCallbackEnabled } from '../ui/promptCallback.js'; import { ActivityIndicator } from '../ui/activityIndicator.js'; import { NotificationService } from '../utils/notification.js'; @@ -156,6 +158,7 @@ export class AutohandAgent { private notificationService: NotificationService; private versionCheckResult?: VersionCheckResult; private teamManager: TeamManager; + private repeatManager: RepeatManager; private suggestionEngine: SuggestionEngine | null = null; private pendingSuggestion: Promise | null = null; private isStartupSuggestion = false; @@ -288,6 +291,21 @@ export class AutohandAgent { } }); + // Initialize repeat manager for /repeat recurring prompts + this.repeatManager = new RepeatManager(); + this.repeatManager.onTrigger((job) => { + // If the agent is busy processing an instruction, queue for later. + // The main loop will pick it up when the current turn finishes. + if (this.isInstructionActive) { + this.pendingInkInstructions.push(job.prompt); + return; + } + + // Agent is idle — interrupt the blocking prompt so the main loop + // can process the instruction through the normal flow. + promptInterrupt(job.prompt); + }); + // Initialize team manager for /team, /tasks, /message commands this.teamManager = new TeamManager({ leadSessionId: randomUUID(), @@ -821,6 +839,8 @@ export class AutohandAgent { isNonInteractive: runtime.isRpcMode === true, // Team manager for /team, /tasks, /message commands teamManager: this.teamManager, + // Repeat manager for /repeat recurring prompt scheduling + repeatManager: this.repeatManager, }; this.slashHandler = new SlashCommandHandler(slashContext, SLASH_COMMANDS); } @@ -1562,6 +1582,9 @@ If lint or tests fail, report the issues but do NOT commit.`; return command; } + // Echo the user's slash command to the chat log so it's visible + console.log(chalk.white(`\n› ${normalized}`)); + const handled = await this.handleSlashCommand(command, args); if (handled !== null) { // Slash command returned display output - print it, don't send to LLM diff --git a/src/core/slashCommandHandler.ts b/src/core/slashCommandHandler.ts index 811194b..b289495 100644 --- a/src/core/slashCommandHandler.ts +++ b/src/core/slashCommandHandler.ts @@ -379,6 +379,10 @@ export class SlashCommandHandler { const { execute } = await import('../commands/import.js'); return execute(args); } + case '/repeat': { + const { repeat } = await import('../commands/repeat.js'); + return repeat({ repeatManager: this.ctx.repeatManager, llm: this.ctx.llm }, args); + } default: this.printUnsupported(command); return null; diff --git a/src/core/slashCommandTypes.ts b/src/core/slashCommandTypes.ts index ff88c0d..545126a 100644 --- a/src/core/slashCommandTypes.ts +++ b/src/core/slashCommandTypes.ts @@ -14,6 +14,7 @@ import type { AutomodeManager } from './AutomodeManager.js'; import type { FileActionManager } from '../actions/filesystem.js'; import type { McpClientManager } from '../mcp/McpClientManager.js'; import type { TeamManager } from './teams/TeamManager.js'; +import type { RepeatManager } from './RepeatManager.js'; import type { LoadedConfig, ProviderName } from '../types.js'; export interface SlashCommandContext { @@ -62,6 +63,8 @@ export interface SlashCommandContext { isNonInteractive?: boolean; /** Team manager for /team and /tasks commands */ teamManager?: TeamManager; + /** Repeat manager for /repeat recurring prompt scheduling */ + repeatManager?: RepeatManager; } export interface SlashCommand { diff --git a/src/core/slashCommands.ts b/src/core/slashCommands.ts index d57c64f..6bdca83 100644 --- a/src/core/slashCommands.ts +++ b/src/core/slashCommands.ts @@ -47,6 +47,7 @@ import * as teamCmd from '../commands/team.js'; import * as tasksCmd from '../commands/tasks.js'; import * as messageCmd from '../commands/message.js'; import * as importCmd from '../commands/import.js'; +import * as repeatCmd from '../commands/repeat.js'; import type { SlashCommand } from './slashCommandTypes.js'; export type { SlashCommand } from './slashCommandTypes.js'; @@ -103,4 +104,5 @@ export const SLASH_COMMANDS: SlashCommand[] = ([ tasksCmd.metadata, messageCmd.metadata, importCmd.metadata, + repeatCmd.metadata, ] as (SlashCommand | undefined)[]).filter((cmd): cmd is SlashCommand => cmd != null && typeof cmd.command === 'string'); diff --git a/tests/commands/repeat.test.ts b/tests/commands/repeat.test.ts new file mode 100644 index 0000000..4ceea97 --- /dev/null +++ b/tests/commands/repeat.test.ts @@ -0,0 +1,670 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { parseInput, intervalToCron, repeat } from '../../src/commands/repeat.js'; +import { RepeatManager } from '../../src/core/RepeatManager.js'; + +// ─── parseInput tests ─────────────────────────────────────────────────────── + +describe('parseInput', () => { + it('rule 1: leading interval token', () => { + expect(parseInput('5m /babysit-prs')).toMatchObject({ interval: '5m', prompt: '/babysit-prs', explicit: true }); + expect(parseInput('2h check build')).toMatchObject({ interval: '2h', prompt: 'check build', explicit: true }); + expect(parseInput('1d deploy')).toMatchObject({ interval: '1d', prompt: 'deploy', explicit: true }); + expect(parseInput('30s run quick check')).toMatchObject({ interval: '30s', prompt: 'run quick check', explicit: true }); + }); + + it('rule 1: leading token alone (empty prompt)', () => { + expect(parseInput('5m')).toMatchObject({ interval: '5m', prompt: '', explicit: true }); + }); + + it('rule 2: leading "every " clause', () => { + expect(parseInput('every 2 minutes bun run test')).toMatchObject({ interval: '2m', prompt: 'bun run test', explicit: true }); + expect(parseInput('every 5m check deploy')).toMatchObject({ interval: '5m', prompt: 'check deploy', explicit: true }); + expect(parseInput('every 1 hour run backup')).toMatchObject({ interval: '1h', prompt: 'run backup', explicit: true }); + expect(parseInput('every 30 seconds ping')).toMatchObject({ interval: '30s', prompt: 'ping', explicit: true }); + }); + + it('rule 3: trailing "every " (end of input)', () => { + expect(parseInput('check the deploy every 20m')).toMatchObject({ interval: '20m', prompt: 'check the deploy', explicit: true }); + }); + + it('rule 3: trailing "every "', () => { + expect(parseInput('run tests every 5 minutes')).toMatchObject({ interval: '5m', prompt: 'run tests', explicit: true }); + expect(parseInput('check status every 2 hours')).toMatchObject({ interval: '2h', prompt: 'check status', explicit: true }); + expect(parseInput('backup every 1 day')).toMatchObject({ interval: '1d', prompt: 'backup', explicit: true }); + expect(parseInput('ping every 30 seconds')).toMatchObject({ interval: '30s', prompt: 'ping', explicit: true }); + }); + + it('rule 4: middle "every " joins surrounding text', () => { + expect(parseInput('tell me a joke every 10s about life')).toMatchObject({ + interval: '10s', prompt: 'tell me a joke about life', explicit: true, + }); + expect(parseInput('run tests every 2m and report results')).toMatchObject({ + interval: '2m', prompt: 'run tests and report results', explicit: true, + }); + expect(parseInput('check deploy every 1h then notify me')).toMatchObject({ + interval: '1h', prompt: 'check deploy then notify me', explicit: true, + }); + }); + + it('rule 4: middle "every " joins surrounding text', () => { + expect(parseInput('pull changes every 5 minutes and run lint')).toMatchObject({ + interval: '5m', prompt: 'pull changes and run lint', explicit: true, + }); + }); + + it('rule: singular "every " without number implies 1', () => { + expect(parseInput('say hello to me every minute')).toMatchObject({ interval: '1m', prompt: 'say hello to me', explicit: true }); + expect(parseInput('check git commits every hour')).toMatchObject({ interval: '1h', prompt: 'check git commits', explicit: true }); + expect(parseInput('run backup every day')).toMatchObject({ interval: '1d', prompt: 'run backup', explicit: true }); + expect(parseInput('ping every second')).toMatchObject({ interval: '1s', prompt: 'ping', explicit: true }); + }); + + it('rule: leading singular "every "', () => { + expect(parseInput('every minute check the logs')).toMatchObject({ interval: '1m', prompt: 'check the logs', explicit: true }); + expect(parseInput('every hour run tests')).toMatchObject({ interval: '1h', prompt: 'run tests', explicit: true }); + }); + + it('rule: middle singular "every " joins surrounding text', () => { + expect(parseInput('say hello every minute to me')).toMatchObject({ interval: '1m', prompt: 'say hello to me', explicit: true }); + }); + + it('rule 5: "every" not followed by time expression → default', () => { + expect(parseInput('check every PR')).toMatchObject({ interval: '5m', prompt: 'check every PR', explicit: false }); + }); + + it('rule 5: no interval → default 5m', () => { + expect(parseInput('check the deploy')).toMatchObject({ interval: '5m', prompt: 'check the deploy', explicit: false }); + expect(parseInput('run tests')).toMatchObject({ interval: '5m', prompt: 'run tests', explicit: false }); + }); +}); + +// ─── intervalToCron tests ─────────────────────────────────────────────────── + +describe('intervalToCron', () => { + it('minutes that divide 60 cleanly', () => { + expect(intervalToCron('5m')).toMatchObject({ cronExpression: '*/5 * * * *', humanReadable: 'every 5 minutes' }); + expect(intervalToCron('10m')).toMatchObject({ cronExpression: '*/10 * * * *' }); + expect(intervalToCron('15m')).toMatchObject({ cronExpression: '*/15 * * * *' }); + expect(intervalToCron('30m')).toMatchObject({ cronExpression: '*/30 * * * *' }); + expect(intervalToCron('1m')).toMatchObject({ cronExpression: '*/1 * * * *', humanReadable: 'every 1 minute' }); + }); + + it('minutes that do not divide 60 → rounds to nearest clean divisor', () => { + const result = intervalToCron('7m'); + // 7 doesn't divide 60; nearest divisors of 60 are 6 and 10; 6 is closer + expect(result.cronExpression).toBe('*/6 * * * *'); + expect(result.roundedNote).toBeDefined(); + }); + + it('minutes >= 60 → converts to hours', () => { + const result = intervalToCron('60m'); + expect(result.cronExpression).toBe('0 */1 * * *'); + expect(result.humanReadable).toBe('every 1 hour'); + }); + + it('120m → 2 hours', () => { + const result = intervalToCron('120m'); + expect(result.cronExpression).toBe('0 */2 * * *'); + }); + + it('hours that divide 24', () => { + expect(intervalToCron('2h')).toMatchObject({ cronExpression: '0 */2 * * *', humanReadable: 'every 2 hours' }); + expect(intervalToCron('6h')).toMatchObject({ cronExpression: '0 */6 * * *' }); + expect(intervalToCron('12h')).toMatchObject({ cronExpression: '0 */12 * * *' }); + }); + + it('hours that do not divide 24 → rounds', () => { + const result = intervalToCron('7h'); + // 7 doesn't divide 24; nearest divisors are 6 and 8; 6 wins (found first at equal distance) + expect(result.cronExpression).toBe('0 */6 * * *'); + expect(result.roundedNote).toBeDefined(); + }); + + it('days', () => { + expect(intervalToCron('1d')).toMatchObject({ cronExpression: '0 0 */1 * *', humanReadable: 'every 1 day at midnight' }); + expect(intervalToCron('3d')).toMatchObject({ cronExpression: '0 0 */3 * *' }); + }); + + it('seconds → rounds up to minutes', () => { + const result = intervalToCron('30s'); + expect(result.cronExpression).toBe('*/1 * * * *'); + expect(result.roundedNote).toContain('Rounded'); + }); + + it('90s → 2m', () => { + const result = intervalToCron('90s'); + expect(result.cronExpression).toBe('*/2 * * * *'); + expect(result.roundedNote).toContain('Rounded'); + }); + + it('throws on invalid format', () => { + expect(() => intervalToCron('abc')).toThrow('Invalid interval'); + expect(() => intervalToCron('5x')).toThrow('Invalid interval'); + }); +}); + +// ─── RepeatManager tests ─────────────────────────────────────────────────── + +describe('RepeatManager', () => { + let manager: RepeatManager; + + beforeEach(() => { + vi.useFakeTimers(); + manager = new RepeatManager(); + }); + + afterEach(() => { + manager.shutdown(); + vi.useRealTimers(); + }); + + it('schedules a job and triggers callback', () => { + const cb = vi.fn(); + manager.onTrigger(cb); + + const job = manager.schedule('run tests', 60_000, '*/1 * * * *', 'every 1 minute'); + expect(job.id).toBeDefined(); + expect(job.prompt).toBe('run tests'); + + // Should not have fired yet + expect(cb).not.toHaveBeenCalled(); + + // Advance time by 1 minute + vi.advanceTimersByTime(60_000); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith(job); + + // Advance another minute + vi.advanceTimersByTime(60_000); + expect(cb).toHaveBeenCalledTimes(2); + }); + + it('cancels a job', () => { + const cb = vi.fn(); + manager.onTrigger(cb); + + const job = manager.schedule('run tests', 60_000, '*/1 * * * *', 'every 1 minute'); + const ok = manager.cancel(job.id); + expect(ok).toBe(true); + + vi.advanceTimersByTime(120_000); + expect(cb).not.toHaveBeenCalled(); + }); + + it('cancel returns false for unknown id', () => { + expect(manager.cancel('nonexistent')).toBe(false); + }); + + it('lists active jobs', () => { + manager.schedule('job1', 60_000, '*/1 * * * *', 'every 1 minute'); + manager.schedule('job2', 120_000, '*/2 * * * *', 'every 2 minutes'); + expect(manager.list()).toHaveLength(2); + }); + + it('shutdown clears all jobs', () => { + const cb = vi.fn(); + manager.onTrigger(cb); + + manager.schedule('job1', 60_000, '*/1 * * * *', 'every 1 minute'); + manager.schedule('job2', 60_000, '*/1 * * * *', 'every 1 minute'); + manager.shutdown(); + + expect(manager.list()).toHaveLength(0); + vi.advanceTimersByTime(120_000); + expect(cb).not.toHaveBeenCalled(); + }); + + // ─── maxRuns: auto-cancel after N executions ─────────────────────────── + + it('auto-cancels job after maxRuns triggers', () => { + const cb = vi.fn(); + manager.onTrigger(cb); + + manager.schedule('test', 60_000, '*/1 * * * *', 'every 1 minute', { maxRuns: 3 }); + + // Fire 3 times + vi.advanceTimersByTime(60_000); + vi.advanceTimersByTime(60_000); + vi.advanceTimersByTime(60_000); + expect(cb).toHaveBeenCalledTimes(3); + + // 4th tick should NOT fire — job was auto-cancelled + vi.advanceTimersByTime(60_000); + expect(cb).toHaveBeenCalledTimes(3); + expect(manager.list()).toHaveLength(0); + }); + + it('job without maxRuns keeps running indefinitely', () => { + const cb = vi.fn(); + manager.onTrigger(cb); + + manager.schedule('test', 60_000, '*/1 * * * *', 'every 1 minute'); + + vi.advanceTimersByTime(60_000 * 10); + expect(cb).toHaveBeenCalledTimes(10); + expect(manager.list()).toHaveLength(1); + }); + + it('stores maxRuns and runCount on the job', () => { + manager.schedule('test', 60_000, '*/1 * * * *', 'every 1 minute', { maxRuns: 5 }); + const job = manager.list()[0]; + expect(job.maxRuns).toBe(5); + expect(job.runCount).toBe(0); + }); + + it('increments runCount on each trigger', () => { + const cb = vi.fn(); + manager.onTrigger(cb); + + manager.schedule('test', 60_000, '*/1 * * * *', 'every 1 minute', { maxRuns: 10 }); + + vi.advanceTimersByTime(60_000 * 3); + const job = manager.list()[0]; + expect(job.runCount).toBe(3); + }); + + // ─── custom expiry duration ──────────────────────────────────────────── + + it('uses custom expiresInMs instead of default 3 days', () => { + const cb = vi.fn(); + manager.onTrigger(cb); + + const ONE_HOUR_MS = 60 * 60 * 1000; + manager.schedule('test', 60_000, '*/1 * * * *', 'every 1 minute', { expiresInMs: ONE_HOUR_MS }); + + const job = manager.list()[0]; + expect(job.expiresAt).toBe(job.createdAt + ONE_HOUR_MS); + + // Still alive before expiry + vi.advanceTimersByTime(ONE_HOUR_MS - 1); + expect(manager.list()).toHaveLength(1); + + // Expires at exactly ONE_HOUR_MS + vi.advanceTimersByTime(1); + expect(manager.list()).toHaveLength(0); + }); + + it('default expiry is still 3 days when no custom expiresInMs', () => { + manager.schedule('test', 60_000, '*/1 * * * *', 'every 1 minute'); + const job = manager.list()[0]; + const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000; + expect(job.expiresAt).toBe(job.createdAt + THREE_DAYS_MS); + }); +}); + +// ─── /repeat command handler tests ────────────────────────────────────────── + +describe('/repeat command', () => { + let manager: RepeatManager; + + beforeEach(() => { + vi.useFakeTimers(); + manager = new RepeatManager(); + }); + + afterEach(() => { + manager.shutdown(); + vi.useRealTimers(); + }); + + it('shows usage when called with no args', async () => { + const result = await repeat({ repeatManager: manager }, []); + expect(result).toContain('/repeat'); + expect(result).toContain('Usage'); + }); + + it('shows usage when prompt is empty after parsing', async () => { + const result = await repeat({ repeatManager: manager }, ['5m']); + expect(result).toContain('Usage'); + }); + + it('returns warning when no repeat manager', async () => { + const result = await repeat({ repeatManager: undefined }, ['5m', 'test']); + expect(result).toContain('not available'); + }); + + it('schedules a job with leading interval', async () => { + const result = await repeat({ repeatManager: manager }, ['5m', 'run', 'tests']); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('run tests'); + expect(result).toContain('every 5 minutes'); + expect(manager.list()).toHaveLength(1); + }); + + it('schedules a job with trailing every clause', async () => { + const result = await repeat({ repeatManager: manager }, ['check', 'deploy', 'every', '20m']); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('check deploy'); + expect(result).toContain('every 20 minutes'); + }); + + it('schedules with default interval when none specified', async () => { + const result = await repeat({ repeatManager: manager }, ['check', 'the', 'deploy']); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('every 5 minutes'); + }); + + it('cancel subcommand cancels a job', async () => { + const job = manager.schedule('test', 60_000, '*/1 * * * *', 'every 1 minute'); + const result = await repeat({ repeatManager: manager }, ['cancel', job.id]); + expect(result).toContain('Cancelled'); + expect(manager.list()).toHaveLength(0); + }); + + it('cancel subcommand with invalid id', async () => { + const result = await repeat({ repeatManager: manager }, ['cancel', 'bad-id']); + expect(result).toContain('No active job'); + }); + + it('cancel subcommand with no id shows usage', async () => { + const result = await repeat({ repeatManager: manager }, ['cancel']); + expect(result).toContain('Usage'); + }); + + it('list subcommand shows active jobs', async () => { + manager.schedule('job1', 60_000, '*/1 * * * *', 'every 1 minute'); + const result = await repeat({ repeatManager: manager }, ['list']); + expect(result).toContain('Active recurring jobs'); + expect(result).toContain('job1'); + }); + + it('list subcommand with no jobs', async () => { + const result = await repeat({ repeatManager: manager }, ['list']); + expect(result).toContain('No recurring jobs'); + }); + + it('schedules a job with middle every clause', async () => { + const result = await repeat( + { repeatManager: manager }, + 'tell me a joke every 10s about life'.split(' '), + ); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('tell me a joke about life'); + // 10s rounds to 1m + expect(result).toContain('every 1 minute'); + expect(manager.list()).toHaveLength(1); + }); + + it('shows rounding note for non-clean intervals', async () => { + const result = await repeat({ repeatManager: manager }, ['7m', 'run', 'lint']); + expect(result).toContain('Note:'); + }); + + // ─── LLM intent extraction tests ──────────────────────────────────────── + + it('uses LLM to extract interval from natural language when regex falls through', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"2m","prompt":"navigate to my gh pr list and pick one item to work on"}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + 'navigate to my gh pr list and pick one item to work on twice a minute'.split(' '), + ); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('every 2 minutes'); + expect(result).toContain('navigate to my gh pr list and pick one item to work on'); + expect(mockLlm.complete).toHaveBeenCalledTimes(1); + }); + + it('uses LLM for ambiguous "hourly" phrasing', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"1h","prompt":"make a git commit with empty message to trigger ci cd"}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + 'make a git commit with empty message to trigger ci cd hourly'.split(' '), + ); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('every 1 hour'); + expect(mockLlm.complete).toHaveBeenCalledTimes(1); + }); + + it('always calls LLM first when available, even for explicit intervals', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"5m","prompt":"run tests"}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + ['5m', 'run', 'tests'], + ); + expect(result).toContain('Recurring job scheduled'); + expect(mockLlm.complete).toHaveBeenCalledTimes(1); + }); + + it('falls back to regex when LLM returns invalid JSON', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: 'I cannot parse this input', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + ['run', 'tests'], + ); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('every 5 minutes'); // default interval + expect(result).toContain('run tests'); + }); + + it('falls back to regex when LLM throws', async () => { + const mockLlm = { + complete: vi.fn().mockRejectedValue(new Error('API error')), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + ['run', 'tests'], + ); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('every 5 minutes'); + }); + + it('falls back to regex when LLM returns invalid interval format', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"every 5 minutes","prompt":"run tests"}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + ['run', 'tests'], + ); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('every 5 minutes'); // default + }); + + it('works without LLM (no llm in context)', async () => { + const result = await repeat( + { repeatManager: manager }, + ['check', 'deploy'], + ); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('every 5 minutes'); + }); + + // ─── BUG 2: subcommand typo detection ────────────────────────────────── + + it('suggests "list" when user types "lsit"', async () => { + const result = await repeat({ repeatManager: manager }, ['lsit']); + expect(result).not.toContain('Recurring job scheduled'); + expect(result).toContain('list'); + expect(manager.list()).toHaveLength(0); + }); + + it('suggests "cancel" when user types "cancle" alone', async () => { + const result = await repeat({ repeatManager: manager }, ['cancle']); + expect(result).not.toContain('Recurring job scheduled'); + expect(result).toContain('cancel'); + expect(manager.list()).toHaveLength(0); + }); + + it('shows usage when user types "help"', async () => { + const result = await repeat({ repeatManager: manager }, ['help']); + expect(result).toContain('Usage'); + expect(manager.list()).toHaveLength(0); + }); + + it('suggests "help" when user types "hepl"', async () => { + const result = await repeat({ repeatManager: manager }, ['hepl']); + expect(result).not.toContain('Recurring job scheduled'); + expect(result).toContain('help'); + expect(manager.list()).toHaveLength(0); + }); + + it('does not flag multi-word prompts as typos', async () => { + const result = await repeat({ repeatManager: manager }, ['lint', 'the', 'codebase']); + expect(result).toContain('Recurring job scheduled'); + }); + + it('does not flag words far from subcommands', async () => { + const result = await repeat({ repeatManager: manager }, ['deploy']); + expect(result).toContain('Recurring job scheduled'); + }); + + // ─── maxRuns: LLM extracts execution count ───────────────────────────── + + it('schedules with maxRuns when LLM extracts count', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"2m","prompt":"tell me a joke","maxRuns":10}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + 'tell me a joke every 2 minutes only 10 times'.split(' '), + ); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('10 runs'); + const job = manager.list()[0]; + expect(job.maxRuns).toBe(10); + }); + + it('schedules without maxRuns when LLM omits it', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"5m","prompt":"run tests"}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + ['run', 'tests', 'every', '5m'], + ); + expect(result).toContain('Recurring job scheduled'); + expect(result).not.toContain('runs'); + const job = manager.list()[0]; + expect(job.maxRuns).toBeUndefined(); + }); + + // ─── custom expiry: LLM extracts duration ────────────────────────────── + + it('schedules with custom expiry when LLM extracts expiresIn', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"1d","prompt":"check PRs","expiresIn":"7d"}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + 'check PRs every day for the next week'.split(' '), + ); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('7 day'); + const job = manager.list()[0]; + const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + expect(job.expiresAt).toBe(job.createdAt + SEVEN_DAYS_MS); + }); + + it('defaults to 3 days expiry when LLM omits expiresIn', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"5m","prompt":"run tests"}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + ['5m', 'run', 'tests'], + ); + expect(result).toContain('auto-expire after 3 days'); + }); + + // ─── Disambiguation: conflicting interval signals ─────────────────── + + it('resolves conflicting intervals: "every day" + "intervals of 10 minutes per run" → 10m wins', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"10m","prompt":"run this command echo hello world","expiresIn":"1d"}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + 'do this every day run this command echo hello world until tomorrow with intervals of 10 minutes per run'.split(' '), + ); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('every 10 minutes'); + expect(result).toContain('1 day'); + const job = manager.list()[0]; + expect(job.intervalMs).toBe(10 * 60 * 1000); + }); + + it('resolves "every day at 10am for the next week" → interval 1d, expires 7d', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"1d","prompt":"run the build","expiresIn":"7d"}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + 'every day at 10 am for the next week run the build'.split(' '), + ); + expect(result).toContain('every 1 day'); + expect(result).toContain('7 day'); + }); + + it('resolves "hourly for 2 days only 5 times" → all three fields', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"1h","prompt":"check deployment status","maxRuns":5,"expiresIn":"2d"}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + 'check deployment status hourly for 2 days only 5 times'.split(' '), + ); + expect(result).toContain('every 1 hour'); + expect(result).toContain('5 runs'); + expect(result).toContain('2 day'); + }); + + it('shows both maxRuns and custom expiry in confirmation', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"1h","prompt":"check build","maxRuns":5,"expiresIn":"2d"}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + 'check build every hour only 5 times for 2 days'.split(' '), + ); + expect(result).toContain('5 runs'); + expect(result).toContain('2 day'); + }); +}); From 4074df3d36594554d3c0404084636d2ce0cba6fd Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Thu, 12 Mar 2026 14:02:57 +1300 Subject: [PATCH 3/3] feat: add --repeat CLI flag for non-interactive recurring mode Adds parseRepeatFlag() and buildRepeatRunConfig() for the --repeat CLI flag, enabling headless recurring prompts without the interactive shell. Supports two-arg form (interval + prompt) and single-string natural language parsing with automatic interval extraction. Handles --max-runs and --expires options, validates interval formats, preserves technical content like shell commands and file paths in prompts, and falls back to a 5-minute default when no interval is detected. --- src/commands/repeatCli.ts | 142 +++++++++++++++++ tests/commands/repeatCli.test.ts | 252 +++++++++++++++++++++++++++++++ 2 files changed, 394 insertions(+) create mode 100644 src/commands/repeatCli.ts create mode 100644 tests/commands/repeatCli.test.ts diff --git a/src/commands/repeatCli.ts b/src/commands/repeatCli.ts new file mode 100644 index 0000000..17d2c86 --- /dev/null +++ b/src/commands/repeatCli.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2025 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + * + * repeatCli — parsing and config for the --repeat CLI flag (non-interactive mode). + * + * Usage: + * autohand --repeat "" "" + * autohand --repeat "every 5 minutes run tests" + * autohand --repeat "5m" "run tests" --max-runs 10 --expires "7d" + */ + +import { parseInput, intervalToCron } from './repeat.js'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface RepeatFlagOptions { + maxRuns?: number; + expires?: string; +} + +export interface RepeatFlagResult { + interval: string; + prompt: string; + maxRuns?: number; + expiresIn?: string; + error?: string; +} + +export interface RepeatRunConfig { + intervalMs: number; + prompt: string; + maxRuns?: number; + expiresInMs: number; + cronExpression: string; + humanReadable: string; +} + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const VALID_INTERVAL_RE = /^\d+[smhd]$/; +const INTERVAL_ATTEMPT_RE = /^\d+[a-zA-Z]+$/; +const VALID_SHORTHAND_RE = /^\d+[smhd]$/; +const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000; + +// ─── parseRepeatFlag ───────────────────────────────────────────────────────── + +/** + * Parse --repeat CLI flag arguments into a structured result. + * + * Supports: + * - Two-arg: parseRepeatFlag('5m', 'run tests') + * - Single natural language: parseRepeatFlag('every 5 minutes run tests') + * - Options: { maxRuns, expires } + */ +export function parseRepeatFlag( + firstArg: string, + prompt?: string, + options?: RepeatFlagOptions, +): RepeatFlagResult { + const trimmedFirst = firstArg.trim(); + + // Empty / whitespace input + if (!trimmedFirst) { + return { interval: '', prompt: '', error: 'No input provided. Usage: --repeat "" ""' }; + } + + let interval: string; + let taskPrompt: string; + + if (VALID_INTERVAL_RE.test(trimmedFirst)) { + // First arg is a valid interval like '5m', '2h' + const trimmedPrompt = prompt?.trim() ?? ''; + if (!trimmedPrompt) { + return { interval: trimmedFirst, prompt: '', error: 'No prompt provided. Usage: --repeat "" ""' }; + } + interval = trimmedFirst; + taskPrompt = trimmedPrompt; + } else if (INTERVAL_ATTEMPT_RE.test(trimmedFirst)) { + // Looks like an interval attempt but has invalid unit (e.g. '5x') + return { interval: '', prompt: '', error: `Invalid interval format: "${trimmedFirst}". Use where unit is s, m, h, or d.` }; + } else { + // Not an interval — combine with prompt and use natural language parsing + const combined = prompt ? `${trimmedFirst} ${prompt}`.trim() : trimmedFirst; + const parsed = parseInput(combined); + interval = parsed.interval; + taskPrompt = parsed.prompt; + + if (!taskPrompt.trim()) { + return { interval, prompt: '', error: 'Could not extract a prompt from the input.' }; + } + } + + // Apply CLI options + const maxRuns = options?.maxRuns !== undefined && options.maxRuns > 0 + ? options.maxRuns + : undefined; + + const expiresIn = options?.expires && VALID_SHORTHAND_RE.test(options.expires) + ? options.expires + : undefined; + + return { interval, prompt: taskPrompt, maxRuns, expiresIn }; +} + +// ─── buildRepeatRunConfig ──────────────────────────────────────────────────── + +/** + * Convert a parsed RepeatFlagResult into a runtime RepeatRunConfig. + */ +export function buildRepeatRunConfig( + parsed: Pick, +): RepeatRunConfig { + const cron = intervalToCron(parsed.interval); + const expiresInMs = parsed.expiresIn ? shorthandToMs(parsed.expiresIn) : THREE_DAYS_MS; + + return { + intervalMs: cron.intervalMs, + prompt: parsed.prompt, + maxRuns: parsed.maxRuns, + expiresInMs, + cronExpression: cron.cronExpression, + humanReadable: cron.humanReadable, + }; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function shorthandToMs(shorthand: string): number { + const match = shorthand.match(/^(\d+)([smhd])$/); + if (!match) return THREE_DAYS_MS; + const n = parseInt(match[1], 10); + const unit = match[2]; + switch (unit) { + case 's': return n * 1000; + case 'm': return n * 60 * 1000; + case 'h': return n * 60 * 60 * 1000; + case 'd': return n * 24 * 60 * 60 * 1000; + default: return THREE_DAYS_MS; + } +} diff --git a/tests/commands/repeatCli.test.ts b/tests/commands/repeatCli.test.ts new file mode 100644 index 0000000..42cadf9 --- /dev/null +++ b/tests/commands/repeatCli.test.ts @@ -0,0 +1,252 @@ +/** + * @license + * Copyright 2025 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + * + * Tests for the --repeat CLI flag (non-interactive mode). + * Validates parsing, scheduling, execution, and edge cases for + * `autohand --repeat "" ""`. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { parseRepeatFlag, type RepeatFlagOptions } from '../../src/commands/repeatCli.js'; + +// ─── parseRepeatFlag tests ────────────────────────────────────────────────── + +describe('parseRepeatFlag', () => { + // ─── Basic parsing ──────────────────────────────────────────────────── + + it('parses explicit interval and prompt: --repeat "5m" "run tests"', () => { + const result = parseRepeatFlag('5m', 'run tests'); + expect(result.interval).toBe('5m'); + expect(result.prompt).toBe('run tests'); + }); + + it('parses single natural language string: --repeat "every 5 minutes run tests"', () => { + const result = parseRepeatFlag('every 5 minutes run tests'); + expect(result.interval).toBe('5m'); + expect(result.prompt).toBe('run tests'); + }); + + it('parses shorthand intervals: s, m, h, d', () => { + expect(parseRepeatFlag('30s', 'ping').interval).toBe('30s'); + expect(parseRepeatFlag('10m', 'check').interval).toBe('10m'); + expect(parseRepeatFlag('2h', 'backup').interval).toBe('2h'); + expect(parseRepeatFlag('1d', 'report').interval).toBe('1d'); + }); + + it('defaults to 5m when no interval is specified', () => { + const result = parseRepeatFlag('run tests'); + expect(result.interval).toBe('5m'); + expect(result.prompt).toBe('run tests'); + }); + + // ─── maxRuns from CLI ───────────────────────────────────────────────── + + it('parses --max-runs option', () => { + const result = parseRepeatFlag('5m', 'run tests', { maxRuns: 10 }); + expect(result.maxRuns).toBe(10); + }); + + it('omits maxRuns when not specified', () => { + const result = parseRepeatFlag('5m', 'run tests'); + expect(result.maxRuns).toBeUndefined(); + }); + + it('rejects maxRuns of 0', () => { + const result = parseRepeatFlag('5m', 'run tests', { maxRuns: 0 }); + expect(result.maxRuns).toBeUndefined(); + }); + + it('rejects negative maxRuns', () => { + const result = parseRepeatFlag('5m', 'run tests', { maxRuns: -5 }); + expect(result.maxRuns).toBeUndefined(); + }); + + // ─── expiresIn from CLI ─────────────────────────────────────────────── + + it('parses --expires option as shorthand', () => { + const result = parseRepeatFlag('5m', 'run tests', { expires: '7d' }); + expect(result.expiresIn).toBe('7d'); + }); + + it('omits expiresIn when not specified', () => { + const result = parseRepeatFlag('5m', 'run tests'); + expect(result.expiresIn).toBeUndefined(); + }); + + it('rejects invalid expires format', () => { + const result = parseRepeatFlag('5m', 'run tests', { expires: 'next week' }); + expect(result.expiresIn).toBeUndefined(); + }); + + // ─── Edge cases: empty and whitespace ───────────────────────────────── + + it('returns error for empty input', () => { + const result = parseRepeatFlag(''); + expect(result.error).toBeDefined(); + }); + + it('returns error for whitespace-only input', () => { + const result = parseRepeatFlag(' '); + expect(result.error).toBeDefined(); + }); + + it('returns error when prompt is empty after interval extraction', () => { + const result = parseRepeatFlag('5m', ''); + expect(result.error).toBeDefined(); + }); + + it('returns error when prompt is only whitespace', () => { + const result = parseRepeatFlag('5m', ' '); + expect(result.error).toBeDefined(); + }); + + // ─── Edge cases: invalid intervals ──────────────────────────────────── + + it('returns error for invalid interval format', () => { + const result = parseRepeatFlag('5x', 'run tests'); + expect(result.error).toBeDefined(); + }); + + it('returns error for interval with no unit', () => { + const result = parseRepeatFlag('123', 'run tests'); + // '123' doesn't match \d+[smhd], should be treated as prompt text + expect(result.interval).toBe('5m'); // default + expect(result.prompt).toContain('123'); + }); + + // ─── Natural language: "every" variants ─────────────────────────────── + + it('parses "every 2 hours check build status"', () => { + const result = parseRepeatFlag('every 2 hours check build status'); + expect(result.interval).toBe('2h'); + expect(result.prompt).toBe('check build status'); + }); + + it('parses "run tests every 5 minutes"', () => { + const result = parseRepeatFlag('run tests every 5 minutes'); + expect(result.interval).toBe('5m'); + expect(result.prompt).toBe('run tests'); + }); + + it('parses "check deploy every day"', () => { + const result = parseRepeatFlag('check deploy every day'); + expect(result.interval).toBe('1d'); + expect(result.prompt).toBe('check deploy'); + }); + + // ─── Preserves technical content ────────────────────────────────────── + + it('preserves slash commands in prompt', () => { + const result = parseRepeatFlag('5m', '/deploy staging'); + expect(result.prompt).toBe('/deploy staging'); + }); + + it('preserves file paths in prompt', () => { + const result = parseRepeatFlag('10m', 'lint src/core/*.ts'); + expect(result.prompt).toBe('lint src/core/*.ts'); + }); + + it('preserves shell commands with special chars', () => { + const result = parseRepeatFlag('5m', 'echo "hello world"'); + expect(result.prompt).toBe('echo "hello world"'); + }); + + it('preserves backtick commands in prompt', () => { + const result = parseRepeatFlag('5m', 'run `git status` and report'); + expect(result.prompt).toBe('run `git status` and report'); + }); + + // ─── Combined options ───────────────────────────────────────────────── + + it('combines interval, maxRuns, and expires', () => { + const result = parseRepeatFlag('10m', 'run tests', { maxRuns: 5, expires: '2d' }); + expect(result.interval).toBe('10m'); + expect(result.prompt).toBe('run tests'); + expect(result.maxRuns).toBe(5); + expect(result.expiresIn).toBe('2d'); + }); + + it('two-arg form: interval and prompt are separate strings', () => { + const result = parseRepeatFlag('2h', 'check PR reviews'); + expect(result.interval).toBe('2h'); + expect(result.prompt).toBe('check PR reviews'); + }); + + // ─── Disambiguation: conflicting intervals ──────────────────────────── + + it('single-string with only a prompt (no schedule keywords)', () => { + const result = parseRepeatFlag('check every PR for review comments'); + // "every PR" is a quantifier, not a frequency + expect(result.interval).toBe('5m'); // default + expect(result.prompt).toContain('every PR'); + }); + + it('handles very long prompts without truncation', () => { + const longPrompt = 'check all the deployment logs and look for errors in the kubernetes pods and report back with a summary of the issues found including pod names and timestamps'; + const result = parseRepeatFlag('5m', longPrompt); + expect(result.prompt).toBe(longPrompt); + }); +}); + +// ─── buildRepeatRunConfig tests ───────────────────────────────────────────── + +describe('buildRepeatRunConfig', () => { + it('converts parsed flag to RepeatRunConfig with intervalMs', async () => { + const { buildRepeatRunConfig } = await import('../../src/commands/repeatCli.js'); + const config = buildRepeatRunConfig({ + interval: '5m', + prompt: 'run tests', + }); + expect(config.intervalMs).toBe(5 * 60 * 1000); + expect(config.prompt).toBe('run tests'); + }); + + it('includes maxRuns in config when specified', async () => { + const { buildRepeatRunConfig } = await import('../../src/commands/repeatCli.js'); + const config = buildRepeatRunConfig({ + interval: '10m', + prompt: 'check build', + maxRuns: 3, + }); + expect(config.maxRuns).toBe(3); + }); + + it('includes expiresInMs in config when specified', async () => { + const { buildRepeatRunConfig } = await import('../../src/commands/repeatCli.js'); + const config = buildRepeatRunConfig({ + interval: '1h', + prompt: 'check PRs', + expiresIn: '7d', + }); + expect(config.expiresInMs).toBe(7 * 24 * 60 * 60 * 1000); + }); + + it('defaults expiresInMs to 3 days when not specified', async () => { + const { buildRepeatRunConfig } = await import('../../src/commands/repeatCli.js'); + const config = buildRepeatRunConfig({ + interval: '5m', + prompt: 'test', + }); + const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000; + expect(config.expiresInMs).toBe(THREE_DAYS_MS); + }); + + it('computes cronExpression from interval', async () => { + const { buildRepeatRunConfig } = await import('../../src/commands/repeatCli.js'); + const config = buildRepeatRunConfig({ + interval: '5m', + prompt: 'test', + }); + expect(config.cronExpression).toBe('*/5 * * * *'); + }); + + it('computes humanReadable from interval', async () => { + const { buildRepeatRunConfig } = await import('../../src/commands/repeatCli.js'); + const config = buildRepeatRunConfig({ + interval: '2h', + prompt: 'test', + }); + expect(config.humanReadable).toContain('2 hour'); + }); +});