summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--pi/agent/extensions/loop-scheduler/index.ts80
1 files changed, 73 insertions, 7 deletions
diff --git a/pi/agent/extensions/loop-scheduler/index.ts b/pi/agent/extensions/loop-scheduler/index.ts
index ec7c9bb..1a02182 100644
--- a/pi/agent/extensions/loop-scheduler/index.ts
+++ b/pi/agent/extensions/loop-scheduler/index.ts
@@ -31,6 +31,7 @@ interface LoopJob {
createdAt: number;
nextRunAt: number;
pending: boolean;
+ paused: boolean; // true when the job is globally paused via /loop pause
runs: number;
lastRunAt?: number;
}
@@ -146,7 +147,8 @@ function parseLoopRequest(rawArgs: string): { prompt: string; intervalMs: number
}
function formatJobLine(job: LoopJob): string {
- return `${job.id} every ${job.intervalLabel} ${job.pending ? "(pending)" : formatDelay(job.nextRunAt - Date.now())} ${shortenPrompt(job.prompt)}`;
+ const state = job.paused ? "(paused)" : job.pending ? "(pending)" : formatDelay(job.nextRunAt - Date.now());
+ return `${job.id} every ${job.intervalLabel} ${state} ${shortenPrompt(job.prompt)}`;
}
interface LoopPreset {
@@ -210,6 +212,7 @@ export default function loopSchedulerExtension(pi: ExtensionAPI): void {
const timers = new Map<string, TimerHandle>();
let lastCtx: ExtensionContext | undefined;
let agentBusy = false;
+ let allPaused = false; // true when all loops are suspended via /loop pause
let uiTick: TimerHandle | undefined;
function rememberContext(ctx: ExtensionContext): void {
@@ -288,12 +291,15 @@ export default function loopSchedulerExtension(pi: ExtensionAPI): void {
return;
}
- ctx.ui.setStatus("loop-scheduler", ctx.ui.theme.fg("accent", `loop:${ordered.length}`));
+ // Status bar: append ⏸ when all loops are paused so the user can see it at a glance.
+ const statusLabel = allPaused ? `loop:${ordered.length} ⏸` : `loop:${ordered.length}`;
+ ctx.ui.setStatus("loop-scheduler", ctx.ui.theme.fg("accent", statusLabel));
ctx.ui.setWidget(
"loop-scheduler",
[
- ctx.ui.theme.fg("accent", "Scheduled loops"),
- ...ordered.slice(0, 3).map((job) => `${job.pending ? "⏸" : "⟳"} ${formatJobLine(job)}`),
+ ctx.ui.theme.fg("accent", allPaused ? "Scheduled loops (paused)" : "Scheduled loops"),
+ // ⏸ = globally paused, ⏳ = pending (agent busy), ⟳ = counting down
+ ...ordered.slice(0, 3).map((job) => `${job.paused ? "⏸" : job.pending ? "⏳" : "⟳"} ${formatJobLine(job)}`),
...(ordered.length > 3 ? [ctx.ui.theme.fg("muted", `+${ordered.length - 3} more`)] : []),
],
{ placement: "belowEditor" },
@@ -357,7 +363,7 @@ export default function loopSchedulerExtension(pi: ExtensionAPI): void {
}
function drainPendingJobs(): void {
- if (agentBusy) return;
+ if (agentBusy || allPaused) return;
const nextPending = getOrderedJobs().find((job) => job.pending);
if (!nextPending) return;
dispatchLoopJob(nextPending, "pending-drain");
@@ -366,6 +372,8 @@ export default function loopSchedulerExtension(pi: ExtensionAPI): void {
async function handleJobDue(id: string): Promise<void> {
const job = jobs.get(id);
if (!job) return;
+ // Guard: if loops were paused after the timer was set, skip firing.
+ if (allPaused) return;
job.nextRunAt = Date.now() + job.intervalMs;
scheduleJobTimer(job);
@@ -388,6 +396,7 @@ export default function loopSchedulerExtension(pi: ExtensionAPI): void {
createdAt: Date.now(),
nextRunAt: Date.now() + intervalMs,
pending: false,
+ paused: allPaused, // inherit the current global pause state so new jobs added while paused start paused
runs: 0,
};
}
@@ -416,9 +425,33 @@ export default function loopSchedulerExtension(pi: ExtensionAPI): void {
updateUi();
}
+ // Suspend all loops: clear every timer and mark each job as paused.
+ // The jobs remain in the map so they can be resumed later.
+ function pauseAllJobs(): void {
+ clearAllTimers();
+ allPaused = true;
+ for (const job of jobs.values()) {
+ job.paused = true;
+ job.pending = false; // pending state is stale once timers are cleared
+ }
+ updateUi();
+ }
+
+ // Resume all paused loops: reset each job's next-run time to a fresh interval
+ // from now, reschedule its timer, and clear the paused flag.
+ function resumeAllJobs(): void {
+ allPaused = false;
+ for (const job of jobs.values()) {
+ job.paused = false;
+ job.nextRunAt = Date.now() + job.intervalMs;
+ scheduleJobTimer(job);
+ }
+ updateUi();
+ }
+
pi.registerCommand("loop", {
description:
- "Schedule a recurring prompt: /loop 10m <prompt>, /loop list, /loop cancel <id|all>, /loop <preset-name>",
+ "Schedule a recurring prompt: /loop 10m <prompt>, /loop list, /loop cancel <id|all>, /loop pause, /loop cont, /loop <preset-name>",
// Provide autocomplete for subcommands and preset names.
//
// CRITICAL: pi's autocomplete.js line 209 does:
@@ -468,6 +501,8 @@ export default function loopSchedulerExtension(pi: ExtensionAPI): void {
const fixed = [
{ value: "list", label: "list", description: "Show active loop jobs" },
{ value: "cancel", label: "cancel", description: "Cancel a job: cancel <id|all>" },
+ { value: "pause", label: "pause", description: "Pause all active loops" },
+ { value: "cont", label: "cont", description: "Continue (resume) all paused loops" },
{ value: "preset", label: "preset", description: "Activate a named preset: preset <name>" },
{ value: "edit", label: "edit", description: "Edit presets file in $EDITOR" },
{ value: "presets", label: "presets", description: "List available presets" },
@@ -495,7 +530,7 @@ 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> | /loop edit | /loop presets | /loop preset <name> | /loop <preset-name>",
+ "Usage: /loop <interval> <prompt> | /loop <prompt> | /loop list | /loop cancel <id|all> | /loop pause | /loop cont | /loop edit | /loop presets | /loop preset <name> | /loop <preset-name>",
"info",
ctx,
);
@@ -530,6 +565,36 @@ export default function loopSchedulerExtension(pi: ExtensionAPI): void {
return;
}
+ // Suspend all active loops without cancelling them.
+ if (/^pause$/i.test(trimmed)) {
+ if (jobs.size === 0) {
+ notify("No active loop jobs to pause.", "info", ctx);
+ return;
+ }
+ if (allPaused) {
+ notify("Loops are already paused. Use /loop cont to resume.", "info", ctx);
+ return;
+ }
+ pauseAllJobs();
+ notify(`Paused ${jobs.size} loop job(s). Use /loop cont to resume.`, "info", ctx);
+ return;
+ }
+
+ // Resume all loops that were suspended with /loop pause.
+ if (/^cont(inue)?$/i.test(trimmed)) {
+ if (jobs.size === 0) {
+ notify("No active loop jobs.", "info", ctx);
+ return;
+ }
+ if (!allPaused) {
+ notify("Loops are not paused.", "info", ctx);
+ return;
+ }
+ resumeAllJobs();
+ notify(`Resumed ${jobs.size} loop job(s).`, "success", ctx);
+ return;
+ }
+
// Open the presets file in $VISUAL/$EDITOR for editing.
if (/^edit$/i.test(trimmed)) {
await openPresetsFile(ctx);
@@ -633,6 +698,7 @@ export default function loopSchedulerExtension(pi: ExtensionAPI): void {
stopUiTick();
jobs.clear();
agentBusy = false;
+ allPaused = false;
updateUi(ctx);
});
}