Skip to content

Commit 5dfbb17

Browse files
authored
Merge pull request #14 from autohandai/igorcosta/add-repeat-command
feat: add /repeat command and --repeat CLI flag
2 parents d5f1a14 + 652d7a3 commit 5dfbb17

File tree

11 files changed

+2041
-10
lines changed

11 files changed

+2041
-10
lines changed

src/commands/repeat.ts

Lines changed: 694 additions & 0 deletions
Large diffs are not rendered by default.

src/commands/repeatCli.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Autohand AI LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*
6+
* repeatCli — parsing and config for the --repeat CLI flag (non-interactive mode).
7+
*
8+
* Usage:
9+
* autohand --repeat "<interval>" "<prompt>"
10+
* autohand --repeat "every 5 minutes run tests"
11+
* autohand --repeat "5m" "run tests" --max-runs 10 --expires "7d"
12+
*/
13+
14+
import { parseInput, intervalToCron } from './repeat.js';
15+
16+
// ─── Types ───────────────────────────────────────────────────────────────────
17+
18+
export interface RepeatFlagOptions {
19+
maxRuns?: number;
20+
expires?: string;
21+
}
22+
23+
export interface RepeatFlagResult {
24+
interval: string;
25+
prompt: string;
26+
maxRuns?: number;
27+
expiresIn?: string;
28+
error?: string;
29+
}
30+
31+
export interface RepeatRunConfig {
32+
intervalMs: number;
33+
prompt: string;
34+
maxRuns?: number;
35+
expiresInMs: number;
36+
cronExpression: string;
37+
humanReadable: string;
38+
}
39+
40+
// ─── Constants ───────────────────────────────────────────────────────────────
41+
42+
const VALID_INTERVAL_RE = /^\d+[smhd]$/;
43+
const INTERVAL_ATTEMPT_RE = /^\d+[a-zA-Z]+$/;
44+
const VALID_SHORTHAND_RE = /^\d+[smhd]$/;
45+
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
46+
47+
// ─── parseRepeatFlag ─────────────────────────────────────────────────────────
48+
49+
/**
50+
* Parse --repeat CLI flag arguments into a structured result.
51+
*
52+
* Supports:
53+
* - Two-arg: parseRepeatFlag('5m', 'run tests')
54+
* - Single natural language: parseRepeatFlag('every 5 minutes run tests')
55+
* - Options: { maxRuns, expires }
56+
*/
57+
export function parseRepeatFlag(
58+
firstArg: string,
59+
prompt?: string,
60+
options?: RepeatFlagOptions,
61+
): RepeatFlagResult {
62+
const trimmedFirst = firstArg.trim();
63+
64+
// Empty / whitespace input
65+
if (!trimmedFirst) {
66+
return { interval: '', prompt: '', error: 'No input provided. Usage: --repeat "<schedule>" "<prompt>"' };
67+
}
68+
69+
let interval: string;
70+
let taskPrompt: string;
71+
72+
if (VALID_INTERVAL_RE.test(trimmedFirst)) {
73+
// First arg is a valid interval like '5m', '2h'
74+
const trimmedPrompt = prompt?.trim() ?? '';
75+
if (!trimmedPrompt) {
76+
return { interval: trimmedFirst, prompt: '', error: 'No prompt provided. Usage: --repeat "<interval>" "<prompt>"' };
77+
}
78+
interval = trimmedFirst;
79+
taskPrompt = trimmedPrompt;
80+
} else if (INTERVAL_ATTEMPT_RE.test(trimmedFirst)) {
81+
// Looks like an interval attempt but has invalid unit (e.g. '5x')
82+
return { interval: '', prompt: '', error: `Invalid interval format: "${trimmedFirst}". Use <number><unit> where unit is s, m, h, or d.` };
83+
} else {
84+
// Not an interval — combine with prompt and use natural language parsing
85+
const combined = prompt ? `${trimmedFirst} ${prompt}`.trim() : trimmedFirst;
86+
const parsed = parseInput(combined);
87+
interval = parsed.interval;
88+
taskPrompt = parsed.prompt;
89+
90+
if (!taskPrompt.trim()) {
91+
return { interval, prompt: '', error: 'Could not extract a prompt from the input.' };
92+
}
93+
}
94+
95+
// Apply CLI options
96+
const maxRuns = options?.maxRuns !== undefined && options.maxRuns > 0
97+
? options.maxRuns
98+
: undefined;
99+
100+
const expiresIn = options?.expires && VALID_SHORTHAND_RE.test(options.expires)
101+
? options.expires
102+
: undefined;
103+
104+
return { interval, prompt: taskPrompt, maxRuns, expiresIn };
105+
}
106+
107+
// ─── buildRepeatRunConfig ────────────────────────────────────────────────────
108+
109+
/**
110+
* Convert a parsed RepeatFlagResult into a runtime RepeatRunConfig.
111+
*/
112+
export function buildRepeatRunConfig(
113+
parsed: Pick<RepeatFlagResult, 'interval' | 'prompt' | 'maxRuns' | 'expiresIn'>,
114+
): RepeatRunConfig {
115+
const cron = intervalToCron(parsed.interval);
116+
const expiresInMs = parsed.expiresIn ? shorthandToMs(parsed.expiresIn) : THREE_DAYS_MS;
117+
118+
return {
119+
intervalMs: cron.intervalMs,
120+
prompt: parsed.prompt,
121+
maxRuns: parsed.maxRuns,
122+
expiresInMs,
123+
cronExpression: cron.cronExpression,
124+
humanReadable: cron.humanReadable,
125+
};
126+
}
127+
128+
// ─── Helpers ─────────────────────────────────────────────────────────────────
129+
130+
function shorthandToMs(shorthand: string): number {
131+
const match = shorthand.match(/^(\d+)([smhd])$/);
132+
if (!match) return THREE_DAYS_MS;
133+
const n = parseInt(match[1], 10);
134+
const unit = match[2];
135+
switch (unit) {
136+
case 's': return n * 1000;
137+
case 'm': return n * 60 * 1000;
138+
case 'h': return n * 60 * 60 * 1000;
139+
case 'd': return n * 24 * 60 * 60 * 1000;
140+
default: return THREE_DAYS_MS;
141+
}
142+
}

src/core/RepeatManager.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Autohand AI LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*
6+
* RepeatManager — in-process scheduler for recurring prompts.
7+
* Jobs run at a fixed interval using setInterval and auto-expire after 3 days.
8+
*/
9+
10+
import { randomUUID } from 'node:crypto';
11+
12+
export interface RepeatJob {
13+
id: string;
14+
prompt: string;
15+
intervalMs: number;
16+
cronExpression: string;
17+
humanInterval: string;
18+
createdAt: number;
19+
expiresAt: number;
20+
/** Maximum number of executions before auto-cancel. Undefined = unlimited. */
21+
maxRuns?: number;
22+
/** Number of times the job has triggered so far. */
23+
runCount: number;
24+
}
25+
26+
export interface ScheduleOptions {
27+
/** Auto-cancel after this many executions. */
28+
maxRuns?: number;
29+
/** Custom expiry duration in ms (overrides the default 3 days). */
30+
expiresInMs?: number;
31+
}
32+
33+
export type RepeatJobCallback = (job: RepeatJob) => void;
34+
35+
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
36+
37+
export class RepeatManager {
38+
private jobs = new Map<string, RepeatJob>();
39+
private timers = new Map<string, ReturnType<typeof setInterval>>();
40+
private expiryTimers = new Map<string, ReturnType<typeof setTimeout>>();
41+
private callback: RepeatJobCallback | null = null;
42+
43+
/**
44+
* Register the callback that fires each time a job triggers.
45+
*/
46+
onTrigger(cb: RepeatJobCallback): void {
47+
this.callback = cb;
48+
}
49+
50+
/**
51+
* Schedule a new recurring job.
52+
* Returns the created RepeatJob.
53+
*/
54+
schedule(prompt: string, intervalMs: number, cronExpression: string, humanInterval: string, options?: ScheduleOptions): RepeatJob {
55+
const id = randomUUID().slice(0, 8);
56+
const now = Date.now();
57+
const expiresInMs = options?.expiresInMs ?? THREE_DAYS_MS;
58+
const job: RepeatJob = {
59+
id,
60+
prompt,
61+
intervalMs,
62+
cronExpression,
63+
humanInterval,
64+
createdAt: now,
65+
expiresAt: now + expiresInMs,
66+
maxRuns: options?.maxRuns,
67+
runCount: 0,
68+
};
69+
70+
this.jobs.set(id, job);
71+
72+
// Set up the recurring interval
73+
const timer = setInterval(() => {
74+
job.runCount++;
75+
if (this.callback) {
76+
this.callback(job);
77+
}
78+
// Auto-cancel when maxRuns limit reached
79+
if (job.maxRuns !== undefined && job.runCount >= job.maxRuns) {
80+
this.cancel(id);
81+
}
82+
}, intervalMs);
83+
84+
// Prevent the interval from keeping the process alive
85+
if (timer.unref) timer.unref();
86+
87+
this.timers.set(id, timer);
88+
89+
// Set up auto-expiry
90+
const expiryTimer = setTimeout(() => {
91+
this.cancel(id);
92+
}, expiresInMs);
93+
94+
if (expiryTimer.unref) expiryTimer.unref();
95+
96+
this.expiryTimers.set(id, expiryTimer);
97+
98+
return job;
99+
}
100+
101+
/**
102+
* Cancel a scheduled job by ID.
103+
* Returns true if the job was found and cancelled.
104+
*/
105+
cancel(id: string): boolean {
106+
const timer = this.timers.get(id);
107+
if (timer) {
108+
clearInterval(timer);
109+
this.timers.delete(id);
110+
}
111+
112+
const expiryTimer = this.expiryTimers.get(id);
113+
if (expiryTimer) {
114+
clearTimeout(expiryTimer);
115+
this.expiryTimers.delete(id);
116+
}
117+
118+
return this.jobs.delete(id);
119+
}
120+
121+
/**
122+
* List all active jobs.
123+
*/
124+
list(): RepeatJob[] {
125+
return [...this.jobs.values()];
126+
}
127+
128+
/**
129+
* Cancel all jobs and clean up.
130+
*/
131+
shutdown(): void {
132+
for (const id of this.jobs.keys()) {
133+
this.cancel(id);
134+
}
135+
}
136+
}

src/core/agent.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { ProviderNotConfiguredError } from '../providers/ProviderFactory.js';
2121
import { ApiError, classifyApiError } from '../providers/errors.js';
2222
import {
2323
getPromptBlockWidth,
24+
promptInterrupt,
2425
promptNotify,
2526
readInstruction,
2627
safeEmitKeypressEvents
@@ -85,6 +86,7 @@ type InkRenderer = any;
8586
import { PermissionManager } from '../permissions/PermissionManager.js';
8687
import { HookManager } from './HookManager.js';
8788
import { TeamManager } from './teams/TeamManager.js';
89+
import { RepeatManager } from './RepeatManager.js';
8890
import { confirm as unifiedConfirm, isExternalCallbackEnabled } from '../ui/promptCallback.js';
8991
import { ActivityIndicator } from '../ui/activityIndicator.js';
9092
import { NotificationService } from '../utils/notification.js';
@@ -157,6 +159,7 @@ export class AutohandAgent {
157159
private notificationService: NotificationService;
158160
private versionCheckResult?: VersionCheckResult;
159161
private teamManager: TeamManager;
162+
private repeatManager: RepeatManager;
160163
private suggestionEngine: SuggestionEngine | null = null;
161164
private pendingSuggestion: Promise<void> | null = null;
162165
private isStartupSuggestion = false;
@@ -289,6 +292,21 @@ export class AutohandAgent {
289292
}
290293
});
291294

295+
// Initialize repeat manager for /repeat recurring prompts
296+
this.repeatManager = new RepeatManager();
297+
this.repeatManager.onTrigger((job) => {
298+
// If the agent is busy processing an instruction, queue for later.
299+
// The main loop will pick it up when the current turn finishes.
300+
if (this.isInstructionActive) {
301+
this.pendingInkInstructions.push(job.prompt);
302+
return;
303+
}
304+
305+
// Agent is idle — interrupt the blocking prompt so the main loop
306+
// can process the instruction through the normal flow.
307+
promptInterrupt(job.prompt);
308+
});
309+
292310
// Initialize team manager for /team, /tasks, /message commands
293311
this.teamManager = new TeamManager({
294312
leadSessionId: randomUUID(),
@@ -836,6 +854,8 @@ export class AutohandAgent {
836854
},
837855
// Team manager for /team, /tasks, /message commands
838856
teamManager: this.teamManager,
857+
// Repeat manager for /repeat recurring prompt scheduling
858+
repeatManager: this.repeatManager,
839859
};
840860
this.slashHandler = new SlashCommandHandler(slashContext, SLASH_COMMANDS);
841861
}
@@ -1577,6 +1597,9 @@ If lint or tests fail, report the issues but do NOT commit.`;
15771597
return command;
15781598
}
15791599

1600+
// Echo the user's slash command to the chat log so it's visible
1601+
console.log(chalk.white(`\n› ${normalized}`));
1602+
15801603
const handled = await this.runSlashCommandWithInput(command, args);
15811604
if (handled !== null) {
15821605
// Slash command returned display output - print it, don't send to LLM

src/core/slashCommandHandler.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,10 @@ export class SlashCommandHandler {
382382
const { execute } = await import('../commands/import.js');
383383
return execute(args);
384384
}
385+
case '/repeat': {
386+
const { repeat } = await import('../commands/repeat.js');
387+
return repeat({ repeatManager: this.ctx.repeatManager, llm: this.ctx.llm }, args);
388+
}
385389
default:
386390
this.printUnsupported(command);
387391
return null;

src/core/slashCommandTypes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { AutomodeManager } from './AutomodeManager.js';
1414
import type { FileActionManager } from '../actions/filesystem.js';
1515
import type { McpClientManager } from '../mcp/McpClientManager.js';
1616
import type { TeamManager } from './teams/TeamManager.js';
17+
import type { RepeatManager } from './RepeatManager.js';
1718
import type { LoadedConfig, ProviderName } from '../types.js';
1819

1920
export interface SlashCommandContext {
@@ -68,6 +69,8 @@ export interface SlashCommandContext {
6869
onTopRecommendation?: (slug: string) => void;
6970
/** Team manager for /team and /tasks commands */
7071
teamManager?: TeamManager;
72+
/** Repeat manager for /repeat recurring prompt scheduling */
73+
repeatManager?: RepeatManager;
7174
}
7275

7376
export interface SlashCommandSubcommand {

src/core/slashCommands.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import * as teamCmd from '../commands/team.js';
4747
import * as tasksCmd from '../commands/tasks.js';
4848
import * as messageCmd from '../commands/message.js';
4949
import * as importCmd from '../commands/import.js';
50+
import * as repeatCmd from '../commands/repeat.js';
5051

5152
import type { SlashCommand } from './slashCommandTypes.js';
5253
export type { SlashCommand } from './slashCommandTypes.js';
@@ -103,4 +104,5 @@ export const SLASH_COMMANDS: SlashCommand[] = ([
103104
tasksCmd.metadata,
104105
messageCmd.metadata,
105106
importCmd.metadata,
107+
repeatCmd.metadata,
106108
] as (SlashCommand | undefined)[]).filter((cmd): cmd is SlashCommand => cmd != null && typeof cmd.command === 'string');

0 commit comments

Comments
 (0)