summaryrefslogtreecommitdiff
path: root/pi/agent/extensions/loop-scheduler
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-22 21:28:48 +0200
committerPaul Buetow <paul@buetow.org>2026-03-22 21:28:48 +0200
commit5e817371a43ba468e5f216230d504b6124c85e95 (patch)
tree1062a05bb733ef5c04405a619deb52b72c6084a6 /pi/agent/extensions/loop-scheduler
parentfc8a5aefff7c8421c579df1ba8391fdde411d731 (diff)
fix
Diffstat (limited to 'pi/agent/extensions/loop-scheduler')
-rw-r--r--pi/agent/extensions/loop-scheduler/README.md124
-rw-r--r--pi/agent/extensions/loop-scheduler/index.ts380
2 files changed, 0 insertions, 504 deletions
diff --git a/pi/agent/extensions/loop-scheduler/README.md b/pi/agent/extensions/loop-scheduler/README.md
deleted file mode 100644
index 78a6635..0000000
--- a/pi/agent/extensions/loop-scheduler/README.md
+++ /dev/null
@@ -1,124 +0,0 @@
-# Loop Scheduler
-
-Session-scoped recurring prompts for Pi.
-
-This extension adds a Claude-Code-style `/loop` command for interactive Pi
-sessions. It schedules a prompt to be re-sent on an interval while the current
-Pi process stays open.
-
-## Commands
-
-- `/loop 10m <prompt>`
- Run a prompt every 10 minutes.
-- `/loop <prompt>`
- Run a prompt every 10 minutes using the default interval.
-- `/loop <prompt> every 2h`
- Alternative trailing interval form.
-- `/loop list`
- Show the active loop jobs.
-- `/loop cancel <id>`
- Cancel one loop job.
-- `/loop cancel all`
- Cancel all loop jobs.
-
-Supported units:
-
-- `s`
-- `m`
-- `h`
-- `d`
-
-Examples:
-
-- `5s`
-- `10m`
-- `2h`
-- `1d`
-- `every 2 hours`
-- `hourly`
-- `daily`
-
-## Usage Flows
-
-### Flow 1: Poll something on an interval
-
-Start Pi in the repo, then run:
-
-```text
-/loop 10m check whether the deployment finished and summarize what changed
-```
-
-Pi will keep re-injecting that prompt every 10 minutes while the session stays
-open.
-
-### Flow 2: Loop another command
-
-The scheduled prompt can itself be a slash command or workflow:
-
-```text
-/loop 20m /work-on-tasks highest-impact 1
-```
-
-or:
-
-```text
-/loop 30m /subagent Review the current working tree for concrete regressions only
-```
-
-### Flow 3: Check what is scheduled
-
-```text
-/loop list
-```
-
-This prints the current loop IDs, cadence, next due time, and prompt preview.
-
-### Flow 4: Cancel a loop
-
-Cancel one loop:
-
-```text
-/loop cancel ab12cd34
-```
-
-Cancel everything:
-
-```text
-/loop cancel all
-```
-
-## Busy-Agent Behavior
-
-Loop jobs do not spam turns while Pi is busy.
-
-- if a job becomes due while the agent is running, it is marked pending
-- when the current work finishes, the next pending loop fires once
-- missed intervals do not stack into a catch-up storm
-
-## Session Model
-
-This extension is session-scoped, not durable scheduling.
-
-- loop jobs live only in the current Pi process
-- closing Pi ends all loop jobs
-- `/reload` or a restart drops the active schedules
-- this is for active coding sessions, not unattended automation
-
-## Good Uses
-
-- poll build or deployment status
-- re-run a review command every N minutes
-- check Taskwarrior progress during a work session
-- periodically ask for a summary while you are coding
-
-## Bad Uses
-
-- long-term unattended automation
-- guaranteed exact-time scheduling
-- anything that must survive terminal exit or Pi restart
-
-## Notes
-
-- `/loop` is intended for interactive or RPC sessions that remain open.
-- It is not useful in one-shot `pi -p` mode because the process exits before
- later runs can fire.
diff --git a/pi/agent/extensions/loop-scheduler/index.ts b/pi/agent/extensions/loop-scheduler/index.ts
deleted file mode 100644
index 837214f..0000000
--- a/pi/agent/extensions/loop-scheduler/index.ts
+++ /dev/null
@@ -1,380 +0,0 @@
-import { randomUUID } from "node:crypto";
-import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
-
-const DEFAULT_INTERVAL_MS = 10 * 60 * 1000;
-const MAX_JOBS = 50;
-
-interface LoopJob {
- id: string;
- prompt: string;
- intervalMs: number;
- intervalLabel: string;
- createdAt: number;
- nextRunAt: number;
- pending: boolean;
- runs: number;
- lastRunAt?: number;
-}
-
-type TimerHandle = ReturnType<typeof setTimeout>;
-
-function pluralize(value: number, singular: string): string {
- return `${value}${singular}`;
-}
-
-function formatInterval(ms: number): string {
- if (ms % (24 * 60 * 60 * 1000) === 0) return pluralize(ms / (24 * 60 * 60 * 1000), "d");
- if (ms % (60 * 60 * 1000) === 0) return pluralize(ms / (60 * 60 * 1000), "h");
- if (ms % (60 * 1000) === 0) return pluralize(ms / (60 * 1000), "m");
- if (ms % 1000 === 0) return pluralize(ms / 1000, "s");
- return `${ms}ms`;
-}
-
-function formatDelay(ms: number): string {
- if (ms <= 0) return "due now";
- if (ms < 60 * 1000) return `in ${Math.ceil(ms / 1000)}s`;
- if (ms < 60 * 60 * 1000) return `in ${Math.ceil(ms / (60 * 1000))}m`;
- if (ms < 24 * 60 * 60 * 1000) return `in ${Math.ceil(ms / (60 * 60 * 1000))}h`;
- return `in ${Math.ceil(ms / (24 * 60 * 60 * 1000))}d`;
-}
-
-function shortenPrompt(prompt: string, limit = 72): string {
- return prompt.length > limit ? `${prompt.slice(0, limit)}...` : prompt;
-}
-
-function parseDurationPhrase(raw: string): { intervalMs: number; label: string } | undefined {
- const text = raw.trim().toLowerCase();
- if (!text) return undefined;
-
- if (text === "hourly" || text === "every hour") return { intervalMs: 60 * 60 * 1000, label: "1h" };
- if (text === "daily" || text === "every day") return { intervalMs: 24 * 60 * 60 * 1000, label: "1d" };
- if (text === "minutely" || text === "every minute") return { intervalMs: 60 * 1000, label: "1m" };
-
- const match = text.match(
- /^(?:every\s+)?(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)$/i,
- );
- if (!match) return undefined;
-
- const amount = Number(match[1]);
- if (!Number.isFinite(amount) || amount <= 0) return undefined;
-
- const unit = match[2].toLowerCase();
- let intervalMs = 0;
- let label = "";
-
- if (["s", "sec", "secs", "second", "seconds"].includes(unit)) {
- intervalMs = amount * 1000;
- label = `${amount}s`;
- } else if (["m", "min", "mins", "minute", "minutes"].includes(unit)) {
- intervalMs = amount * 60 * 1000;
- label = `${amount}m`;
- } else if (["h", "hr", "hrs", "hour", "hours"].includes(unit)) {
- intervalMs = amount * 60 * 60 * 1000;
- label = `${amount}h`;
- } else if (["d", "day", "days"].includes(unit)) {
- intervalMs = amount * 24 * 60 * 60 * 1000;
- label = `${amount}d`;
- }
-
- if (intervalMs <= 0) return undefined;
- return { intervalMs, label };
-}
-
-function parseLoopRequest(rawArgs: string): { prompt: string; intervalMs: number; intervalLabel: string } | undefined {
- const text = rawArgs.trim();
- if (!text) return undefined;
-
- const trailingEvery = text.match(/^(.*\S)\s+every\s+(.+)$/i);
- if (trailingEvery) {
- const prompt = trailingEvery[1].trim();
- const duration = parseDurationPhrase(trailingEvery[2]);
- if (prompt && duration) {
- return { prompt, intervalMs: duration.intervalMs, intervalLabel: duration.label };
- }
- }
-
- const words = text.split(/\s+/);
- if (words.length > 1) {
- const firstDuration = parseDurationPhrase(words[0] ?? "");
- if (firstDuration) {
- return {
- prompt: words.slice(1).join(" "),
- intervalMs: firstDuration.intervalMs,
- intervalLabel: firstDuration.label,
- };
- }
-
- if ((words[0] ?? "").toLowerCase() === "every") {
- for (let i = 2; i <= Math.min(words.length - 1, 4); i++) {
- const candidate = words.slice(0, i).join(" ");
- const duration = parseDurationPhrase(candidate);
- if (duration) {
- return {
- prompt: words.slice(i).join(" "),
- intervalMs: duration.intervalMs,
- intervalLabel: duration.label,
- };
- }
- }
- }
- }
-
- return {
- prompt: text,
- intervalMs: DEFAULT_INTERVAL_MS,
- intervalLabel: formatInterval(DEFAULT_INTERVAL_MS),
- };
-}
-
-function formatJobLine(job: LoopJob): string {
- return `${job.id} every ${job.intervalLabel} ${job.pending ? "(pending)" : formatDelay(job.nextRunAt - Date.now())} ${shortenPrompt(job.prompt)}`;
-}
-
-export default function loopSchedulerExtension(pi: ExtensionAPI): void {
- const jobs = new Map<string, LoopJob>();
- const timers = new Map<string, TimerHandle>();
- let lastCtx: ExtensionContext | undefined;
- let agentBusy = false;
-
- function rememberContext(ctx: ExtensionContext): void {
- lastCtx = ctx;
- }
-
- function clearJobTimer(id: string): void {
- const timer = timers.get(id);
- if (timer) {
- clearTimeout(timer);
- timers.delete(id);
- }
- }
-
- function clearAllTimers(): void {
- for (const timer of timers.values()) {
- clearTimeout(timer);
- }
- timers.clear();
- }
-
- function getOrderedJobs(): LoopJob[] {
- return [...jobs.values()].sort((a, b) => a.nextRunAt - b.nextRunAt || a.createdAt - b.createdAt);
- }
-
- function writeCommandOutput(text: string): void {
- process.stdout.write(`${text}\n`);
- }
-
- function updateUi(ctx: ExtensionContext | undefined = lastCtx): void {
- if (!ctx?.hasUI) return;
-
- const ordered = getOrderedJobs();
- if (ordered.length === 0) {
- ctx.ui.setStatus("loop-scheduler", undefined);
- ctx.ui.setWidget("loop-scheduler", undefined);
- return;
- }
-
- ctx.ui.setStatus("loop-scheduler", ctx.ui.theme.fg("accent", `loop:${ordered.length}`));
- ctx.ui.setWidget(
- "loop-scheduler",
- [
- ctx.ui.theme.fg("accent", "Scheduled loops"),
- ...ordered.slice(0, 3).map((job) => `${job.pending ? "⏸" : "⟳"} ${formatJobLine(job)}`),
- ...(ordered.length > 3 ? [ctx.ui.theme.fg("muted", `+${ordered.length - 3} more`)] : []),
- ],
- { placement: "belowEditor" },
- );
- }
-
- function notify(message: string, level: "info" | "warning" | "error" | "success" = "info", ctx?: ExtensionContext): void {
- const target = ctx ?? lastCtx;
- if (target?.hasUI) {
- target.ui.notify(message, level);
- } else {
- writeCommandOutput(message);
- }
- }
-
- function scheduleJobTimer(job: LoopJob): void {
- clearJobTimer(job.id);
- const delayMs = Math.max(100, job.nextRunAt - Date.now());
- const timer = setTimeout(() => {
- void handleJobDue(job.id);
- }, delayMs);
- timers.set(job.id, timer);
- }
-
- function dispatchLoopJob(job: LoopJob, reason: "timer" | "pending-drain"): void {
- if (agentBusy) {
- job.pending = true;
- updateUi();
- return;
- }
-
- agentBusy = true;
- job.pending = false;
- job.runs += 1;
- job.lastRunAt = Date.now();
- updateUi();
-
- try {
- pi.sendUserMessage(job.prompt);
- notify(`Loop ${job.id} fired (${reason}).`, "info");
- } catch (error) {
- agentBusy = false;
- job.pending = true;
- updateUi();
- const message = error instanceof Error ? error.message : String(error);
- notify(`Loop ${job.id} could not fire yet: ${message}`, "warning");
- }
- }
-
- function drainPendingJobs(): void {
- if (agentBusy) return;
- const nextPending = getOrderedJobs().find((job) => job.pending);
- if (!nextPending) return;
- dispatchLoopJob(nextPending, "pending-drain");
- }
-
- async function handleJobDue(id: string): Promise<void> {
- const job = jobs.get(id);
- if (!job) return;
-
- job.nextRunAt = Date.now() + job.intervalMs;
- scheduleJobTimer(job);
-
- if (agentBusy) {
- job.pending = true;
- updateUi();
- return;
- }
-
- dispatchLoopJob(job, "timer");
- }
-
- function createJob(prompt: string, intervalMs: number, intervalLabel: string): LoopJob {
- return {
- id: randomUUID().replace(/-/g, "").slice(0, 8),
- prompt,
- intervalMs,
- intervalLabel,
- createdAt: Date.now(),
- nextRunAt: Date.now() + intervalMs,
- pending: false,
- runs: 0,
- };
- }
-
- function resolveJob(idOrPrefix: string): LoopJob | undefined {
- const needle = idOrPrefix.trim().toLowerCase();
- if (!needle) return undefined;
-
- const exact = jobs.get(needle);
- if (exact) return exact;
-
- const matches = [...jobs.values()].filter((job) => job.id.startsWith(needle));
- return matches.length === 1 ? matches[0] : undefined;
- }
-
- function formatJobList(): string {
- const ordered = getOrderedJobs();
- if (ordered.length === 0) return "No active loop jobs.";
-
- return ordered.map((job) => `- ${formatJobLine(job)}`).join("\n");
- }
-
- function cancelJob(job: LoopJob): void {
- clearJobTimer(job.id);
- jobs.delete(job.id);
- updateUi();
- }
-
- pi.registerCommand("loop", {
- description: "Schedule a recurring prompt: /loop 10m <prompt>, /loop list, /loop cancel <id|all>",
- handler: async (args, ctx) => {
- rememberContext(ctx);
-
- if (!ctx.hasUI) {
- writeCommandOutput("The /loop command requires an interactive or RPC session that stays open.");
- return;
- }
-
- const trimmed = args.trim();
- if (!trimmed || trimmed.toLowerCase() === "help") {
- notify("Usage: /loop <interval> <prompt> | /loop <prompt> | /loop list | /loop cancel <id|all>", "info", ctx);
- return;
- }
-
- if (/^(list|ls)$/i.test(trimmed)) {
- notify(formatJobList(), "info", ctx);
- updateUi(ctx);
- return;
- }
-
- const cancelAll = /^(cancel|clear)\s+all$/i.test(trimmed);
- if (cancelAll) {
- const count = jobs.size;
- clearAllTimers();
- jobs.clear();
- updateUi(ctx);
- notify(count > 0 ? `Canceled ${count} loop job(s).` : "No active loop jobs.", "info", ctx);
- return;
- }
-
- const cancelMatch = trimmed.match(/^(?:cancel|rm|delete)\s+(\S+)$/i);
- if (cancelMatch) {
- const job = resolveJob(cancelMatch[1]);
- if (!job) {
- notify(`No loop job matched '${cancelMatch[1]}'.`, "warning", ctx);
- return;
- }
- cancelJob(job);
- notify(`Canceled loop ${job.id}.`, "info", ctx);
- return;
- }
-
- if (jobs.size >= MAX_JOBS) {
- notify(`Too many active loop jobs (${jobs.size}). Cancel one before adding another.`, "warning", ctx);
- return;
- }
-
- const request = parseLoopRequest(trimmed);
- if (!request || !request.prompt.trim()) {
- notify("Could not parse /loop arguments. Example: /loop 10m check the build", "warning", ctx);
- return;
- }
-
- const job = createJob(request.prompt.trim(), request.intervalMs, request.intervalLabel);
- jobs.set(job.id, job);
- scheduleJobTimer(job);
- updateUi(ctx);
- notify(`Scheduled loop ${job.id} every ${job.intervalLabel}: ${shortenPrompt(job.prompt)}`, "success", ctx);
- },
- });
-
- pi.on("session_start", async (_event, ctx) => {
- rememberContext(ctx);
- agentBusy = false;
- updateUi(ctx);
- });
-
- pi.on("agent_start", async (_event, ctx) => {
- rememberContext(ctx);
- agentBusy = true;
- updateUi(ctx);
- });
-
- pi.on("agent_end", async (_event, ctx) => {
- rememberContext(ctx);
- agentBusy = false;
- updateUi(ctx);
- drainPendingJobs();
- });
-
- pi.on("session_shutdown", async (_event, ctx) => {
- rememberContext(ctx);
- clearAllTimers();
- jobs.clear();
- agentBusy = false;
- updateUi(ctx);
- });
-}