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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
694 changes: 694 additions & 0 deletions src/commands/repeat.ts

Large diffs are not rendered by default.

142 changes: 142 additions & 0 deletions src/commands/repeatCli.ts
Original file line number Diff line number Diff line change
@@ -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 "<interval>" "<prompt>"
* 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 "<schedule>" "<prompt>"' };
}

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>" "<prompt>"' };
}
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 <number><unit> 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<RepeatFlagResult, 'interval' | 'prompt' | 'maxRuns' | 'expiresIn'>,
): 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;
}
}
136 changes: 136 additions & 0 deletions src/core/RepeatManager.ts
Original file line number Diff line number Diff line change
@@ -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<string, RepeatJob>();
private timers = new Map<string, ReturnType<typeof setInterval>>();
private expiryTimers = new Map<string, ReturnType<typeof setTimeout>>();
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);
}
}
}
23 changes: 23 additions & 0 deletions src/core/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { ProviderNotConfiguredError } from '../providers/ProviderFactory.js';
import { ApiError, classifyApiError } from '../providers/errors.js';
import {
getPromptBlockWidth,
promptInterrupt,
promptNotify,
readInstruction,
safeEmitKeypressEvents
Expand Down Expand Up @@ -85,6 +86,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';
Expand Down Expand Up @@ -157,6 +159,7 @@ export class AutohandAgent {
private notificationService: NotificationService;
private versionCheckResult?: VersionCheckResult;
private teamManager: TeamManager;
private repeatManager: RepeatManager;
private suggestionEngine: SuggestionEngine | null = null;
private pendingSuggestion: Promise<void> | null = null;
private isStartupSuggestion = false;
Expand Down Expand Up @@ -289,6 +292,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(),
Expand Down Expand Up @@ -836,6 +854,8 @@ export class AutohandAgent {
},
// 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);
}
Expand Down Expand Up @@ -1577,6 +1597,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.runSlashCommandWithInput(command, args);
if (handled !== null) {
// Slash command returned display output - print it, don't send to LLM
Expand Down
4 changes: 4 additions & 0 deletions src/core/slashCommandHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,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;
Expand Down
3 changes: 3 additions & 0 deletions src/core/slashCommandTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -68,6 +69,8 @@ export interface SlashCommandContext {
onTopRecommendation?: (slug: string) => void;
/** Team manager for /team and /tasks commands */
teamManager?: TeamManager;
/** Repeat manager for /repeat recurring prompt scheduling */
repeatManager?: RepeatManager;
}

export interface SlashCommandSubcommand {
Expand Down
2 changes: 2 additions & 0 deletions src/core/slashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Loading
Loading