summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-25 21:29:52 +0200
committerPaul Buetow <paul@buetow.org>2026-03-25 21:29:52 +0200
commitfd0e54ec5bafe232ca69ae594d93b8711ac0e43a (patch)
treeaa3e185d799af391867027fd1113e9196c92bd9b
parent3a795bda8684e55e2a754f47d1470eacc0667814 (diff)
loop-scheduler: add named presets with /loop edit, /loop presets, and autocomplete
- New loop-presets.md file (alongside index.ts) with format: * name: INTERVAL prompt - /loop edit opens the presets file in $VISUAL/$EDITOR, using TUI stop/restart pattern - /loop presets lists all loaded presets with intervals and prompt previews - /loop <name> activates a named preset (single-word args only, no collision with interval syntax) - getArgumentCompletions provides tab-completion for subcommands and preset names Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--pi/agent/extensions/loop-scheduler/index.ts174
-rw-r--r--pi/agent/extensions/loop-scheduler/loop-presets.md10
2 files changed, 182 insertions, 2 deletions
diff --git a/pi/agent/extensions/loop-scheduler/index.ts b/pi/agent/extensions/loop-scheduler/index.ts
index 05fb002..75f2ae1 100644
--- a/pi/agent/extensions/loop-scheduler/index.ts
+++ b/pi/agent/extensions/loop-scheduler/index.ts
@@ -1,9 +1,27 @@
+import { readFileSync, writeFileSync, existsSync } from "node:fs";
+import { spawnSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+import path from "node:path";
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;
+// Path to the presets markdown file, stored alongside this extension.
+const PRESETS_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), "loop-presets.md");
+
+// Starter content written to the presets file if it doesn't exist yet.
+const PRESETS_TEMPLATE = `# Loop presets
+# Format: * name: INTERVAL prompt text
+# INTERVAL supports: 5s, 10m, 2h, 1d, hourly, daily, every 30 minutes
+#
+# Examples — uncomment and adjust to taste:
+# * health: 5m check the build status
+# * review: 1h review the last 10 git commits
+# * monitor: 10m check if there are any errors in the logs
+`;
+
interface LoopJob {
id: string;
prompt: string;
@@ -130,6 +148,62 @@ function formatJobLine(job: LoopJob): string {
return `${job.id} every ${job.intervalLabel} ${job.pending ? "(pending)" : formatDelay(job.nextRunAt - Date.now())} ${shortenPrompt(job.prompt)}`;
}
+interface LoopPreset {
+ name: string; // lowercase canonical name
+ intervalMs: number;
+ intervalLabel: string;
+ prompt: string;
+}
+
+// Parse a single "* name: INTERVAL prompt text" line. Returns undefined for non-matching lines
+// (blank lines, comments, and malformed entries are silently skipped).
+function parsePresetLine(line: string): LoopPreset | undefined {
+ const trimmed = line.trim();
+ if (!trimmed.startsWith("* ")) return undefined;
+ const rest = trimmed.slice(2);
+ const colonIdx = rest.indexOf(": ");
+ if (colonIdx === -1) return undefined;
+ const name = rest.slice(0, colonIdx).trim().toLowerCase();
+ if (!name) return undefined;
+ const afterColon = rest.slice(colonIdx + 2).trim();
+ const spaceIdx = afterColon.search(/\s/);
+ if (spaceIdx === -1) return undefined;
+ const intervalToken = afterColon.slice(0, spaceIdx);
+ const prompt = afterColon.slice(spaceIdx + 1).trim();
+ if (!prompt) return undefined;
+ const duration = parseDurationPhrase(intervalToken);
+ if (!duration) return undefined;
+ return { name, intervalMs: duration.intervalMs, intervalLabel: duration.label, prompt };
+}
+
+// Read and parse the presets file fresh on each call. Returns [] on any error (file not found, etc.).
+function loadPresets(): LoopPreset[] {
+ try {
+ const content = readFileSync(PRESETS_FILE, "utf8");
+ return content
+ .split("\n")
+ .map(parsePresetLine)
+ .filter((p): p is LoopPreset => p !== undefined);
+ } catch {
+ return [];
+ }
+}
+
+// Case-insensitive name lookup from the presets file.
+function lookupPreset(name: string): LoopPreset | undefined {
+ return loadPresets().find((p) => p.name === name.trim().toLowerCase());
+}
+
+// Human-readable preset list for /loop presets output.
+function formatPresetList(): string {
+ const presets = loadPresets();
+ if (presets.length === 0) {
+ return `No presets loaded. Use /loop edit to create ${PRESETS_FILE}`;
+ }
+ const lines = presets.map((p) => ` ${p.name} (${p.intervalLabel}): ${shortenPrompt(p.prompt, 60)}`);
+ return [`Presets from ${PRESETS_FILE}:`, ...lines].join("\n");
+}
+
export default function loopSchedulerExtension(pi: ExtensionAPI): void {
const jobs = new Map<string, LoopJob>();
const timers = new Map<string, TimerHandle>();
@@ -141,6 +215,44 @@ export default function loopSchedulerExtension(pi: ExtensionAPI): void {
lastCtx = ctx;
}
+ // Open the presets file in $VISUAL/$EDITOR, seeding it with the template if it doesn't exist.
+ // Follows the same TUI stop/restart pattern as fresh-subagent's openInExternalEditor to avoid
+ // terminal editor fighting the TUI for terminal control.
+ async function openPresetsFile(ctx: ExtensionContext): Promise<void> {
+ if (!existsSync(PRESETS_FILE)) {
+ try {
+ writeFileSync(PRESETS_FILE, PRESETS_TEMPLATE, "utf8");
+ } catch (err) {
+ notify(`Could not create presets file: ${err instanceof Error ? err.message : String(err)}`, "error", ctx);
+ return;
+ }
+ }
+
+ const editor = process.env.VISUAL ?? process.env.EDITOR;
+ if (!editor) {
+ notify(`No editor configured. Set $VISUAL or $EDITOR. File: ${PRESETS_FILE}`, "warning", ctx);
+ return;
+ }
+
+ const command = `exec ${editor} ${JSON.stringify(PRESETS_FILE)}`;
+
+ if (!ctx.hasUI) {
+ spawnSync("bash", ["-lc", command], { stdio: "inherit", env: process.env });
+ return;
+ }
+
+ await ctx.waitForIdle();
+ await ctx.ui.custom<void>((tui, _theme, _kb, done) => {
+ tui.stop();
+ process.stdout.write("\x1b[2J\x1b[H");
+ spawnSync("bash", ["-lc", command], { stdio: "inherit", env: process.env });
+ tui.start();
+ tui.requestRender(true);
+ done(undefined);
+ return { render: () => [], invalidate: () => {} };
+ });
+ }
+
function clearJobTimer(id: string): void {
const timer = timers.get(id);
if (timer) {
@@ -304,7 +416,27 @@ export default function loopSchedulerExtension(pi: ExtensionAPI): void {
}
pi.registerCommand("loop", {
- description: "Schedule a recurring prompt: /loop 10m <prompt>, /loop list, /loop cancel <id|all>",
+ description:
+ "Schedule a recurring prompt: /loop 10m <prompt>, /loop list, /loop cancel <id|all>, /loop <preset-name>",
+ // Provide autocomplete for subcommands and preset names loaded from loop-presets.md.
+ getArgumentCompletions: (prefix: string) => {
+ const fixed = [
+ { value: "list", label: "list", description: "Show active loop jobs" },
+ { value: "cancel", label: "cancel", description: "Cancel: cancel <id|all>" },
+ { value: "edit", label: "edit", description: "Edit presets file in $EDITOR" },
+ { value: "presets", label: "presets", description: "List available presets" },
+ ];
+ const presetItems = loadPresets().map((p) => ({
+ value: p.name,
+ label: p.name,
+ description: `every ${p.intervalLabel} — ${shortenPrompt(p.prompt, 50)}`,
+ }));
+ const all = [...fixed, ...presetItems];
+ if (!prefix) return all;
+ const lower = prefix.toLowerCase();
+ const filtered = all.filter((item) => item.value.startsWith(lower));
+ return filtered.length > 0 ? filtered : null;
+ },
handler: async (args, ctx) => {
rememberContext(ctx);
@@ -315,7 +447,11 @@ export default function loopSchedulerExtension(pi: ExtensionAPI): void {
const trimmed = args.trim();
if (!trimmed || trimmed.toLowerCase() === "help") {
- notify("Usage: /loop <interval> <prompt> | /loop <prompt> | /loop list | /loop cancel <id|all>", "info", ctx);
+ notify(
+ "Usage: /loop <interval> <prompt> | /loop <prompt> | /loop list | /loop cancel <id|all> | /loop edit | /loop presets | /loop <preset-name>",
+ "info",
+ ctx,
+ );
return;
}
@@ -347,6 +483,40 @@ export default function loopSchedulerExtension(pi: ExtensionAPI): void {
return;
}
+ // Open the presets file in $VISUAL/$EDITOR for editing.
+ if (/^edit$/i.test(trimmed)) {
+ await openPresetsFile(ctx);
+ return;
+ }
+
+ // List all available named presets from loop-presets.md.
+ if (/^presets?$/i.test(trimmed)) {
+ notify(formatPresetList(), "info", ctx);
+ return;
+ }
+
+ // If the argument is a single word (no spaces), check if it matches a preset name.
+ // Note: a preset named "hourly" or "daily" takes precedence over the interval shorthand.
+ if (!/\s/.test(trimmed)) {
+ const preset = lookupPreset(trimmed);
+ if (preset) {
+ if (jobs.size >= MAX_JOBS) {
+ notify(`Too many active loop jobs (${jobs.size}). Cancel one first.`, "warning", ctx);
+ return;
+ }
+ const job = createJob(preset.prompt, preset.intervalMs, preset.intervalLabel);
+ jobs.set(job.id, job);
+ scheduleJobTimer(job);
+ updateUi(ctx);
+ notify(
+ `Scheduled loop ${job.id} [${preset.name}] every ${job.intervalLabel}: ${shortenPrompt(job.prompt)}`,
+ "success",
+ ctx,
+ );
+ return;
+ }
+ }
+
if (jobs.size >= MAX_JOBS) {
notify(`Too many active loop jobs (${jobs.size}). Cancel one before adding another.`, "warning", ctx);
return;
diff --git a/pi/agent/extensions/loop-scheduler/loop-presets.md b/pi/agent/extensions/loop-scheduler/loop-presets.md
new file mode 100644
index 0000000..61f30d7
--- /dev/null
+++ b/pi/agent/extensions/loop-scheduler/loop-presets.md
@@ -0,0 +1,10 @@
+# Loop presets
+# Format: * name: INTERVAL prompt text
+# INTERVAL supports: 5s, 10m, 2h, 1d, hourly, daily, every 30 minutes
+#
+# Examples — uncomment and adjust to taste:
+# * health: 5m check the build status
+# * review: 1h review the last 10 git commits
+# * monitor: 10m check if there are any errors in the logs
+
+* tasks: 1m automatically start with the next task with fresh context if the current task completed following the agent-task-management skill.