src / loops.ts
src / loops.ts
import { formatResult, runCommand, type RunOptions } from "./shell";
export interface LoopRun {
runNumber: number;
startedAt: string;
ok: boolean;
output: string;
}
interface LoopHandle {
label: string;
command: string;
intervalSec: number;
maxRuns: number;
runsCompleted: number;
startedAt: string;
stopped: boolean;
stopReason: string;
timer: NodeJS.Timeout;
history: LoopRun[];
}
/**
* Interval loops outlive a single tool call, so the registry lives at module
* scope -- it survives for as long as the plugin process does.
*/
const loops = new Map<string, LoopHandle>();
/** Hard ceilings so a model cannot wedge the plugin process. */
export const MAX_CONCURRENT_LOOPS = 5;
export const MAX_RUNS_CEILING = 200;
const HISTORY_LIMIT = 10;
export function loopExists(label: string): boolean {
return loops.has(label);
}
export function activeLoopCount(): number {
let count = 0;
for (const loop of loops.values()) {
if (!loop.stopped) count++;
}
return count;
}
export function startLoop(args: {
label: string;
command: string;
intervalSec: number;
maxRuns: number;
runOptions: RunOptions;
timeoutSec: number;
}): void {
const handle: LoopHandle = {
label: args.label,
command: args.command,
intervalSec: args.intervalSec,
maxRuns: args.maxRuns,
runsCompleted: 0,
startedAt: new Date().toISOString(),
stopped: false,
stopReason: "",
timer: setInterval(() => void tick(), args.intervalSec * 1000),
history: [],
};
async function tick(): Promise<void> {
if (handle.stopped) return;
const startedAt = new Date().toISOString();
const result = await runCommand(handle.command, args.runOptions);
handle.runsCompleted++;
handle.history.push({
runNumber: handle.runsCompleted,
startedAt,
ok: result.ok,
output: formatResult(result, args.timeoutSec),
});
if (handle.history.length > HISTORY_LIMIT) {
handle.history.splice(0, handle.history.length - HISTORY_LIMIT);
}
if (handle.runsCompleted >= handle.maxRuns) {
stopLoop(handle.label, `reached max_runs (${handle.maxRuns})`);
}
}
loops.set(args.label, handle);
// Fire once immediately so the model gets a first result without waiting a
// whole interval.
void tick();
}
export function stopLoop(label: string, reason: string): boolean {
const handle = loops.get(label);
if (handle === undefined || handle.stopped) return false;
clearInterval(handle.timer);
handle.stopped = true;
handle.stopReason = reason;
return true;
}
export function describeLoop(label: string, historyCount: number): string | undefined {
const handle = loops.get(label);
if (handle === undefined) return undefined;
const status = handle.stopped ? `stopped (${handle.stopReason})` : "running";
const lines = [
`Loop "${handle.label}" -- ${status}`,
`Command: ${handle.command}`,
`Every ${handle.intervalSec}s, ${handle.runsCompleted}/${handle.maxRuns} runs completed, started ${handle.startedAt}`,
];
const recent = handle.history.slice(-historyCount);
if (recent.length === 0) {
lines.push("", "No runs recorded yet.");
return lines.join("\n");
}
lines.push("", `Last ${recent.length} run(s):`);
for (const run of recent) {
lines.push(
"",
`--- run ${run.runNumber} at ${run.startedAt} (${run.ok ? "success" : "failure"}) ---`,
run.output,
);
}
return lines.join("\n");
}
export function listLoops(): string {
if (loops.size === 0) return "No loops have been started.";
const lines: string[] = [];
for (const handle of loops.values()) {
const status = handle.stopped ? `stopped (${handle.stopReason})` : "running";
const lastRun = handle.history[handle.history.length - 1];
const outcome =
lastRun === undefined ? "no runs yet" : lastRun.ok ? "last run OK" : "last run FAILED";
lines.push(
`${handle.label}: ${status}, ${handle.runsCompleted}/${handle.maxRuns} runs, every ${handle.intervalSec}s, ${outcome} -- ${handle.command}`,
);
}
return lines.join("\n");
}
import { formatResult, runCommand, type RunOptions } from "./shell";
export interface LoopRun {
runNumber: number;
startedAt: string;
ok: boolean;
output: string;
}
interface LoopHandle {
label: string;
command: string;
intervalSec: number;
maxRuns: number;
runsCompleted: number;
startedAt: string;
stopped: boolean;
stopReason: string;
timer: NodeJS.Timeout;
history: LoopRun[];
}
/**
* Interval loops outlive a single tool call, so the registry lives at module
* scope -- it survives for as long as the plugin process does.
*/
const loops = new Map<string, LoopHandle>();
/** Hard ceilings so a model cannot wedge the plugin process. */
export const MAX_CONCURRENT_LOOPS = 5;
export const MAX_RUNS_CEILING = 200;
const HISTORY_LIMIT = 10;
export function loopExists(label: string): boolean {
return loops.has(label);
}
export function activeLoopCount(): number {
let count = 0;
for (const loop of loops.values()) {
if (!loop.stopped) count++;
}
return count;
}
export function startLoop(args: {
label: string;
command: string;
intervalSec: number;
maxRuns: number;
runOptions: RunOptions;
timeoutSec: number;
}): void {
const handle: LoopHandle = {
label: args.label,
command: args.command,
intervalSec: args.intervalSec,
maxRuns: args.maxRuns,
runsCompleted: 0,
startedAt: new Date().toISOString(),
stopped: false,
stopReason: "",
timer: setInterval(() => void tick(), args.intervalSec * 1000),
history: [],
};
async function tick(): Promise<void> {
if (handle.stopped) return;
const startedAt = new Date().toISOString();
const result = await runCommand(handle.command, args.runOptions);
handle.runsCompleted++;
handle.history.push({
runNumber: handle.runsCompleted,
startedAt,
ok: result.ok,
output: formatResult(result, args.timeoutSec),
});
if (handle.history.length > HISTORY_LIMIT) {
handle.history.splice(0, handle.history.length - HISTORY_LIMIT);
}
if (handle.runsCompleted >= handle.maxRuns) {
stopLoop(handle.label, `reached max_runs (${handle.maxRuns})`);
}
}
loops.set(args.label, handle);
// Fire once immediately so the model gets a first result without waiting a
// whole interval.
void tick();
}
export function stopLoop(label: string, reason: string): boolean {
const handle = loops.get(label);
if (handle === undefined || handle.stopped) return false;
clearInterval(handle.timer);
handle.stopped = true;
handle.stopReason = reason;
return true;
}
export function describeLoop(label: string, historyCount: number): string | undefined {
const handle = loops.get(label);
if (handle === undefined) return undefined;
const status = handle.stopped ? `stopped (${handle.stopReason})` : "running";
const lines = [
`Loop "${handle.label}" -- ${status}`,
`Command: ${handle.command}`,
`Every ${handle.intervalSec}s, ${handle.runsCompleted}/${handle.maxRuns} runs completed, started ${handle.startedAt}`,
];
const recent = handle.history.slice(-historyCount);
if (recent.length === 0) {
lines.push("", "No runs recorded yet.");
return lines.join("\n");
}
lines.push("", `Last ${recent.length} run(s):`);
for (const run of recent) {
lines.push(
"",
`--- run ${run.runNumber} at ${run.startedAt} (${run.ok ? "success" : "failure"}) ---`,
run.output,
);
}
return lines.join("\n");
}
export function listLoops(): string {
if (loops.size === 0) return "No loops have been started.";
const lines: string[] = [];
for (const handle of loops.values()) {
const status = handle.stopped ? `stopped (${handle.stopReason})` : "running";
const lastRun = handle.history[handle.history.length - 1];
const outcome =
lastRun === undefined ? "no runs yet" : lastRun.ok ? "last run OK" : "last run FAILED";
lines.push(
`${handle.label}: ${status}, ${handle.runsCompleted}/${handle.maxRuns} runs, every ${handle.intervalSec}s, ${outcome} -- ${handle.command}`,
);
}
return lines.join("\n");
}