@trevonistrevon/pi-loop 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,671 @@
1
+ /**
2
+ * @trevonistrevon/pi-loop — A pi extension providing cron/event-based agent re-wake loops and background process monitoring.
3
+ *
4
+ * Tools:
5
+ * LoopCreate — Create a scheduled or event-triggered re-wake loop
6
+ * LoopList — List all active loops with status and next-fire times
7
+ * LoopDelete — Delete or pause a loop by ID
8
+ * MonitorCreate — Start a background command that streams output via pi events
9
+ * MonitorList — List running monitors
10
+ * MonitorStop — Stop a running monitor
11
+ *
12
+ * Commands:
13
+ * /loop — Schedule a re-wake loop: /loop [interval] [prompt]
14
+ * /loops — Interactive menu: view, create, cancel, settings
15
+ */
16
+
17
+ import { randomUUID } from "node:crypto";
18
+ import { join, resolve } from "node:path";
19
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
20
+ import { Type } from "typebox";
21
+ import { parseInterval } from "./loop-parse.js";
22
+ import { MonitorManager } from "./monitor-manager.js";
23
+ import { CronScheduler } from "./scheduler.js";
24
+ import { LoopStore } from "./store.js";
25
+ import { TriggerSystem } from "./trigger-system.js";
26
+ import type { LoopEntry, Trigger } from "./types.js";
27
+ import { LoopWidget, type UICtx } from "./ui/widget.js";
28
+
29
+ const DEBUG = !!process.env.PI_LOOP_DEBUG;
30
+ function debug(...args: unknown[]) {
31
+ if (DEBUG) console.error("[pi-loop]", ...args);
32
+ }
33
+
34
+ function textResult(msg: string) {
35
+ return { content: [{ type: "text" as const, text: msg }], details: undefined as any };
36
+ }
37
+
38
+ const LOOP_TOOL_NAMES = new Set(["LoopCreate", "LoopList", "LoopDelete", "MonitorCreate", "MonitorList", "MonitorStop"]);
39
+ const REMINDER_INTERVAL = 3;
40
+
41
+ const SYSTEM_REMINDER_TEMPLATE = `<system-reminder>
42
+ Scheduled loop "%propmpt%" fired. Trigger: %trigger_info%.
43
+ [loop:%loop_id%]
44
+ </system-reminder>`;
45
+
46
+ export default function (pi: ExtensionAPI) {
47
+ const piLoopEnv = process.env.PI_LOOP;
48
+ const piLoopScope = process.env.PI_LOOP_SCOPE as "memory" | "session" | "project" | undefined;
49
+ let loopScope: "memory" | "session" | "project" = piLoopScope ?? "session";
50
+
51
+ function resolveStorePath(sessionId?: string): string | undefined {
52
+ if (piLoopEnv === "off") return undefined;
53
+ if (piLoopEnv?.startsWith("/")) return piLoopEnv;
54
+ if (piLoopEnv?.startsWith(".")) return resolve(piLoopEnv);
55
+ if (piLoopEnv) return piLoopEnv;
56
+ if (loopScope === "memory") return undefined;
57
+ if (loopScope === "session" && sessionId) {
58
+ return join(process.cwd(), ".pi", "loops", `loops-${sessionId}.json`);
59
+ }
60
+ if (loopScope === "session") return undefined;
61
+ return join(process.cwd(), ".pi", "loops", "loops.json");
62
+ }
63
+
64
+ let store = new LoopStore(resolveStorePath());
65
+ const monitorManager = new MonitorManager(pi);
66
+ let scheduler: CronScheduler;
67
+ let triggerSystem: TriggerSystem;
68
+ const widget = new LoopWidget(store, undefined, monitorManager);
69
+
70
+ scheduler = new CronScheduler(store, onLoopFire);
71
+ widget.setScheduler(scheduler);
72
+ triggerSystem = new TriggerSystem(pi, scheduler);
73
+
74
+ // ── pi-tasks integration ──
75
+ let tasksAvailable = false;
76
+ const _PROTOCOL_VERSION = 1;
77
+
78
+ function checkTasksVersion() {
79
+ const requestId = randomUUID();
80
+ const timer = setTimeout(() => { unsub(); }, 5000);
81
+ const unsub = pi.events.on(`tasks:rpc:ping:reply:${requestId}`, (raw: unknown) => {
82
+ unsub(); clearTimeout(timer);
83
+ const remoteVersion = (raw as any)?.data?.version as number | undefined;
84
+ if (remoteVersion !== undefined) tasksAvailable = true;
85
+ });
86
+ pi.events.emit("tasks:rpc:ping", { requestId });
87
+ }
88
+
89
+ checkTasksVersion();
90
+ pi.events.on("tasks:ready", () => checkTasksVersion());
91
+
92
+ async function autoCreateTask(entry: LoopEntry): Promise<string | undefined> {
93
+ if (!tasksAvailable || !entry.autoTask) return undefined;
94
+ try {
95
+ const requestId = randomUUID();
96
+ const taskId = await new Promise<string | undefined>((resolve, _reject) => {
97
+ const timer = setTimeout(() => { unsub(); resolve(undefined); }, 5000);
98
+ const unsub = pi.events.on(`tasks:rpc:create:reply:${requestId}`, (raw: unknown) => {
99
+ unsub(); clearTimeout(timer);
100
+ const reply = raw as { success: boolean; data?: { id: string }; error?: string };
101
+ if (reply.success && reply.data) resolve(reply.data.id);
102
+ else resolve(undefined);
103
+ });
104
+ pi.events.emit("tasks:rpc:create", {
105
+ requestId,
106
+ subject: entry.prompt.slice(0, 80),
107
+ description: `Auto-created from loop #${entry.id}`,
108
+ metadata: { loopId: entry.id, trigger: entry.trigger },
109
+ });
110
+ });
111
+ return taskId;
112
+ } catch {
113
+ return undefined;
114
+ }
115
+ }
116
+
117
+ // ── Loop fire handler ──
118
+
119
+ function onLoopFire(entry: LoopEntry): void {
120
+ debug(`loop:fire #${entry.id}`, { prompt: entry.prompt.slice(0, 50) });
121
+
122
+ if (entry.autoTask) {
123
+ autoCreateTask(entry).then((taskId) => {
124
+ if (taskId) debug(`loop #${entry.id} → task #${taskId}`);
125
+ });
126
+ }
127
+
128
+ pi.events.emit("loop:fire", {
129
+ loopId: entry.id,
130
+ prompt: entry.prompt,
131
+ trigger: entry.trigger,
132
+ timestamp: Date.now(),
133
+ });
134
+ }
135
+
136
+ // ── Session lifecycle ──
137
+
138
+ let storeUpgraded = false;
139
+ let persistedShown = false;
140
+ let _latestCtx: ExtensionContext | undefined;
141
+
142
+ function upgradeStoreIfNeeded(ctx: ExtensionContext) {
143
+ if (storeUpgraded) return;
144
+ if (loopScope === "session" && !piLoopEnv) {
145
+ const sessionId = ctx.sessionManager.getSessionId();
146
+ const path = resolveStorePath(sessionId);
147
+ store = new LoopStore(path);
148
+ widget.setStore(store);
149
+ scheduler = new CronScheduler(store, onLoopFire);
150
+ widget.setScheduler(scheduler);
151
+ triggerSystem = new TriggerSystem(pi, scheduler);
152
+ }
153
+ storeUpgraded = true;
154
+ }
155
+
156
+ function showPersistedLoops(_isResume = false) {
157
+ if (persistedShown) return;
158
+ persistedShown = true;
159
+ const loops = store.list();
160
+ if (loops.length > 0) {
161
+ store.clearExpired();
162
+ triggerSystem.start();
163
+ widget.update();
164
+ }
165
+ }
166
+
167
+ pi.on("turn_start", async (_event, ctx) => {
168
+ _latestCtx = ctx;
169
+ widget.setUICtx(ctx.ui as UICtx);
170
+ upgradeStoreIfNeeded(ctx);
171
+ });
172
+
173
+ pi.on("before_agent_start", async (_event, ctx) => {
174
+ _latestCtx = ctx;
175
+ widget.setUICtx(ctx.ui as UICtx);
176
+ upgradeStoreIfNeeded(ctx);
177
+ showPersistedLoops();
178
+ });
179
+
180
+ pi.on("session_switch" as any, async (event: any, ctx: ExtensionContext) => {
181
+ _latestCtx = ctx;
182
+ widget.setUICtx(ctx.ui as UICtx);
183
+ triggerSystem.stop();
184
+
185
+ const isResume = event?.reason === "resume";
186
+ storeUpgraded = false;
187
+ persistedShown = false;
188
+
189
+ if (!isResume && loopScope === "memory") {
190
+ store.clearAll();
191
+ }
192
+
193
+ upgradeStoreIfNeeded(ctx);
194
+ showPersistedLoops(isResume);
195
+ });
196
+
197
+ // ── System-reminder injection for loop fires ──
198
+
199
+ let currentTurn = 0;
200
+ let lastLoopToolUseTurn = 0;
201
+ let reminderInjectedThisCycle = false;
202
+ const pendingReminders: string[] = [];
203
+
204
+ pi.on("loop:fire" as any, (data: any) => {
205
+ const triggerInfo = typeof data.trigger === "string"
206
+ ? data.trigger
207
+ : data.trigger?.type === "cron"
208
+ ? `schedule: ${data.trigger.schedule}`
209
+ : data.trigger?.type === "event"
210
+ ? `event: ${data.trigger.source}`
211
+ : `hybrid`;
212
+
213
+ const reminder = SYSTEM_REMINDER_TEMPLATE
214
+ .replace("%prompt%", data.prompt || "loop fired")
215
+ .replace("%trigger_info%", triggerInfo)
216
+ .replace("%loop_id%", data.loopId || "unknown");
217
+
218
+ pendingReminders.push(reminder);
219
+ });
220
+
221
+ pi.on("turn_start", async () => {
222
+ currentTurn++;
223
+ });
224
+
225
+ pi.on("tool_result", async (event) => {
226
+ if (LOOP_TOOL_NAMES.has(event.toolName)) {
227
+ lastLoopToolUseTurn = currentTurn;
228
+ reminderInjectedThisCycle = false;
229
+ return {};
230
+ }
231
+
232
+ if (currentTurn - lastLoopToolUseTurn < REMINDER_INTERVAL || reminderInjectedThisCycle) {
233
+ return {};
234
+ }
235
+
236
+ if (pendingReminders.length === 0) return {};
237
+
238
+ reminderInjectedThisCycle = true;
239
+ lastLoopToolUseTurn = currentTurn;
240
+ const reminder = pendingReminders.shift()!;
241
+ return {
242
+ content: [...event.content, { type: "text" as const, text: reminder }],
243
+ };
244
+ });
245
+
246
+ // ──────────────────────────────────────────────────
247
+ // Tool 1: LoopCreate
248
+ // ──────────────────────────────────────────────────
249
+
250
+ pi.registerTool({
251
+ name: "LoopCreate",
252
+ label: "LoopCreate",
253
+ description: `Create a scheduled repeating task (loop) that runs a prompt on a timer or when an event fires.
254
+
255
+ Use this tool whenever the user asks to:
256
+ - "create a loop" to check something periodically
257
+ - "run every X seconds/minutes/hours"
258
+ - "remind me to check..."
259
+ - "watch for..." or "when X happens, do Y"
260
+ - "schedule a recurring check"
261
+ - set up a periodic monitor or poller
262
+
263
+ DO NOT use raw Bash loops (for/sleep/while). Use LoopCreate instead — it integrates with the session lifecycle, survives across turns, and the scheduler handles timing.
264
+
265
+ ## When NOT to Use
266
+
267
+ Skip this tool when the task is a one-off check (just do it directly) or when the user wants a purely reactive hook.
268
+
269
+ ## Trigger Types
270
+
271
+ - **cron**: time-based. "30s" (rounded to 1m), "5m", "2h", "1d", or full cron like "0 9 * * 1-5"
272
+ - **event**: fires on pi events like "tool_execution_start", "before_agent_start"
273
+ - **hybrid**: both cron + event with debounce
274
+
275
+ ## Parameters
276
+
277
+ - **trigger**: interval like "30s", "5m", "2h", event source, or hybrid spec
278
+ - **prompt**: what to do when the loop fires (e.g., "check if the build passed")
279
+ - **recurring**: repeat or fire once (default: true)
280
+ - **autoTask**: if pi-tasks is loaded, auto-create a task on each fire
281
+ - **triggerType**: "cron", "event", or "hybrid" (inferred if omitted)`,
282
+ promptGuidelines: [
283
+ "When the user asks for a loop, repeating task, periodic check, or scheduled reminder, use LoopCreate — not raw Bash for/sleep/while.",
284
+ "Use LoopCreate for any 'every X seconds/minutes/hours' requests.",
285
+ "After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
286
+ ],
287
+ parameters: Type.Object({
288
+ trigger: Type.String({ description: "Cron expression (e.g., '5m', '1h', '0 9 * * 1-5'), event source (e.g., 'tool_execution_start'), or JSON hybrid spec" }),
289
+ prompt: Type.String({ description: "Prompt to run when the loop fires" }),
290
+ recurring: Type.Optional(Type.Boolean({ description: "Whether loop repeats (default: true)", default: true })),
291
+ autoTask: Type.Optional(Type.Boolean({ description: "Auto-create pi-tasks task on fire", default: false })),
292
+ triggerType: Type.Optional(Type.String({ description: "cron, event, or hybrid (inferred from trigger string if omitted)", enum: ["cron", "event", "hybrid"] })),
293
+ debounceMs: Type.Optional(Type.Number({ description: "Debounce for hybrid triggers (default: 30000)", default: 30000 })),
294
+ }),
295
+
296
+ execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
297
+ const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs } = params;
298
+
299
+ let trigger: Trigger;
300
+ const inferred = triggerType ?? inferTriggerType(triggerInput);
301
+
302
+ if (inferred === "cron") {
303
+ const parsed = parseInterval(triggerInput);
304
+ trigger = { type: "cron", schedule: parsed.cron };
305
+ } else if (inferred === "event") {
306
+ trigger = { type: "event", source: triggerInput };
307
+ } else {
308
+ const cronPart = triggerInput.match(/cron:?\s*(\S+)/)?.[1] || triggerInput;
309
+ const eventPart = triggerInput.match(/event:?\s*(\S+)/)?.[1];
310
+ const parsed = parseInterval(cronPart);
311
+ trigger = {
312
+ type: "hybrid",
313
+ cron: parsed.cron,
314
+ event: { source: eventPart || "tool_execution_start" },
315
+ debounceMs: debounceMs ?? 30000,
316
+ };
317
+ }
318
+
319
+ const entry = store.create(trigger, prompt, {
320
+ recurring: recurring ?? (inferred !== "event"),
321
+ autoTask,
322
+ selfPaced: triggerInput === "self-paced" || prompt.toLowerCase().includes("self-paced"),
323
+ });
324
+
325
+ triggerSystem.add(entry);
326
+ widget.update();
327
+
328
+ const triggerDesc = trigger.type === "cron"
329
+ ? `schedule: ${trigger.schedule}`
330
+ : trigger.type === "event"
331
+ ? `event: ${trigger.source}`
332
+ : `hybrid: cron ${trigger.cron} + event ${trigger.event.source}`;
333
+
334
+ return Promise.resolve(textResult(
335
+ `Loop #${entry.id} created: ${entry.prompt.slice(0, 60)}\n` +
336
+ `Trigger: ${triggerDesc}\n` +
337
+ `Recurring: ${entry.recurring}\n` +
338
+ (entry.autoTask ? `Auto-task: enabled\n` : "") +
339
+ (tasksAvailable ? "" : "(pi-tasks not detected — autoTask will have no effect)\n") +
340
+ `ID: ${entry.id} (use LoopDelete to cancel)`
341
+ ));
342
+ },
343
+ });
344
+
345
+ function inferTriggerType(input: string): "cron" | "event" | "hybrid" {
346
+ if (input.includes("hybrid") || (input.includes("cron") && input.includes("event"))) return "hybrid";
347
+ if (/^\d+\s*[smhd]$/i.test(input.trim())) return "cron";
348
+ if (/^(\*|\d+)/.test(input.trim()) && input.trim().split(/\s+/).length === 5) return "cron";
349
+ return "event";
350
+ }
351
+
352
+ // ──────────────────────────────────────────────────
353
+ // Tool 2: LoopList
354
+ // ──────────────────────────────────────────────────
355
+
356
+ pi.registerTool({
357
+ name: "LoopList",
358
+ label: "LoopList",
359
+ description: `List all active scheduled loops with their IDs, triggers, and next-fire times.
360
+
361
+ Use this before creating new loops to avoid duplicates, or to find IDs for LoopDelete.`,
362
+ parameters: Type.Object({}),
363
+
364
+ execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
365
+ const loops = store.list();
366
+ if (loops.length === 0) return Promise.resolve(textResult("No loops configured. Use LoopCreate to set up a schedule."));
367
+
368
+ const lines: string[] = [];
369
+ for (const entry of loops) {
370
+ const triggerDesc = entry.trigger.type === "cron"
371
+ ? `cron: ${entry.trigger.schedule}`
372
+ : entry.trigger.type === "event"
373
+ ? `event: ${entry.trigger.source}`
374
+ : `hybrid: ${entry.trigger.cron} + ${entry.trigger.event.source}`;
375
+
376
+ const nextFire = entry.trigger.type !== "event"
377
+ ? scheduler.nextFire(entry.id)
378
+ : undefined;
379
+
380
+ const statusIcon = entry.status === "active" ? "⟳" : entry.status === "paused" ? "⏸" : "✗";
381
+ let line = `${statusIcon} #${entry.id} [${entry.status}] ${entry.prompt.slice(0, 60)}`;
382
+ line += ` (${triggerDesc})`;
383
+ if (nextFire) {
384
+ const remaining = Math.max(0, nextFire - Date.now());
385
+ line += ` next: ${formatRemaining(remaining)}`;
386
+ }
387
+ if (entry.autoTask) line += " [auto-task]";
388
+ lines.push(line);
389
+ }
390
+
391
+ return Promise.resolve(textResult(lines.join("\n")));
392
+ },
393
+ });
394
+
395
+ function formatRemaining(ms: number): string {
396
+ if (ms < 60000) return `${Math.round(ms / 1000)}s`;
397
+ if (ms < 3600000) return `${Math.round(ms / 60000)}m`;
398
+ return `${Math.round(ms / 3600000)}h`;
399
+ }
400
+
401
+ // ──────────────────────────────────────────────────
402
+ // Tool 3: LoopDelete
403
+ // ──────────────────────────────────────────────────
404
+
405
+ pi.registerTool({
406
+ name: "LoopDelete",
407
+ label: "LoopDelete",
408
+ description: `Delete or pause a loop by its ID.
409
+
410
+ Use "pause" to temporarily stop a loop without removing it. Use "delete" to permanently remove it.`,
411
+ parameters: Type.Object({
412
+ id: Type.String({ description: "Loop ID to delete or pause" }),
413
+ action: Type.Optional(Type.String({ description: "delete or pause (default: delete)", enum: ["delete", "pause"], default: "delete" })),
414
+ }),
415
+
416
+ execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
417
+ const { id, action } = params;
418
+
419
+ if (action === "pause") {
420
+ const result = store.update(id, { status: "paused" });
421
+ if (!result.entry) return Promise.resolve(textResult(`Loop #${id} not found`));
422
+ triggerSystem.remove(id);
423
+ widget.update();
424
+ return Promise.resolve(textResult(`Loop #${id} paused`));
425
+ }
426
+
427
+ triggerSystem.remove(id);
428
+ const deleted = store.delete(id);
429
+ widget.update();
430
+ if (deleted) return Promise.resolve(textResult(`Loop #${id} deleted`));
431
+ return Promise.resolve(textResult(`Loop #${id} not found`));
432
+ },
433
+ });
434
+
435
+ // ──────────────────────────────────────────────────
436
+ // Tool 4: MonitorCreate
437
+ // ──────────────────────────────────────────────────
438
+
439
+ pi.registerTool({
440
+ name: "MonitorCreate",
441
+ label: "MonitorCreate",
442
+ description: `Start a background command and stream its output via pi events.
443
+
444
+ The monitor runs a shell command in the background. Each output line is emitted as a "monitor:output" pi event. Use this to watch logs, tail files, poll APIs, etc.
445
+
446
+ Events emitted:
447
+ - "monitor:output" — { monitorId, line, timestamp } for each output line
448
+ - "monitor:done" — { monitorId, exitCode } on clean exit
449
+ - "monitor:error" — { monitorId, error } on failure`,
450
+ parameters: Type.Object({
451
+ command: Type.String({ description: "Shell command to run in background" }),
452
+ description: Type.Optional(Type.String({ description: "Human-readable description" })),
453
+ timeout: Type.Optional(Type.Number({ description: "Auto-stop after N ms (default: 300000, 0 = no timeout)", default: 300000 })),
454
+ }),
455
+
456
+ execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
457
+ if (monitorManager.list().filter(m => m.status === "running").length >= 25) {
458
+ return Promise.resolve(textResult("Maximum of 25 running monitors reached. Stop some before creating new ones."));
459
+ }
460
+
461
+ const entry = monitorManager.create(params.command, params.description, params.timeout);
462
+ widget.update();
463
+
464
+ return Promise.resolve(textResult(
465
+ `Monitor #${entry.id} started: ${entry.command.slice(0, 60)}\n` +
466
+ `Output stream: monitor:output (monitorId: ${entry.id})\n` +
467
+ `Timeout: ${params.timeout ? `${params.timeout / 1000}s` : "none"}`
468
+ ));
469
+ },
470
+ });
471
+
472
+ // ──────────────────────────────────────────────────
473
+ // Tool 5: MonitorList
474
+ // ──────────────────────────────────────────────────
475
+
476
+ pi.registerTool({
477
+ name: "MonitorList",
478
+ label: "MonitorList",
479
+ description: `List all monitors with their status, command, and output line count.`,
480
+ parameters: Type.Object({}),
481
+
482
+ execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
483
+ const monitors = monitorManager.list();
484
+ if (monitors.length === 0) return Promise.resolve(textResult("No monitors running."));
485
+
486
+ const lines: string[] = [];
487
+ for (const m of monitors) {
488
+ const icon = m.status === "running" ? "◉" : m.status === "completed" ? "✓" : "✗";
489
+ const age = Date.now() - m.startedAt;
490
+ const ageStr = formatRemaining(age);
491
+ lines.push(`${icon} #${m.id} [${m.status}] ${m.command.slice(0, 50)} — ${m.outputLines} lines (${ageStr})`);
492
+ }
493
+
494
+ return Promise.resolve(textResult(lines.join("\n")));
495
+ },
496
+ });
497
+
498
+ // ──────────────────────────────────────────────────
499
+ // Tool 6: MonitorStop
500
+ // ──────────────────────────────────────────────────
501
+
502
+ pi.registerTool({
503
+ name: "MonitorStop",
504
+ label: "MonitorStop",
505
+ description: `Stop a running monitor. Sends SIGTERM, waits 5s, then SIGKILL.
506
+
507
+ Use MonitorList to find the monitor ID, then stop it with this tool.`,
508
+ parameters: Type.Object({
509
+ monitorId: Type.String({ description: "Monitor ID to stop" }),
510
+ }),
511
+
512
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
513
+ const stopped = await monitorManager.stop(params.monitorId);
514
+ widget.update();
515
+ if (stopped) return textResult(`Monitor #${params.monitorId} stopped`);
516
+ return textResult(`Monitor #${params.monitorId} not found or not running`);
517
+ },
518
+ });
519
+
520
+ // ──────────────────────────────────────────────────
521
+ // /loop command
522
+ // ──────────────────────────────────────────────────
523
+
524
+ pi.registerCommand("loop", {
525
+ description: "Create a repeating scheduled task: /loop [interval] [prompt]. E.g., /loop 5m check the deploy, /loop 30s am I still here",
526
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
527
+ const trimmed = args.trim();
528
+ const ui = ctx.ui;
529
+
530
+ if (!trimmed) {
531
+ const choice = await ui.select("Loop", [
532
+ "Create scheduled loop",
533
+ "Create event-triggered loop",
534
+ "View active loops",
535
+ "Settings",
536
+ ]);
537
+
538
+ if (!choice) return;
539
+ if (choice.startsWith("Create scheduled")) return scheduleLoop(ui);
540
+ if (choice.startsWith("Create event")) return eventLoop(ui);
541
+ if (choice.startsWith("View active")) return viewLoops(ui);
542
+ return settings(ui);
543
+ }
544
+
545
+ const intervalMatch = trimmed.match(/^(\d+\s*[smhdS]\b)/i);
546
+ if (intervalMatch) {
547
+ const interval = intervalMatch[1];
548
+ const prompt = trimmed.slice(intervalMatch[0].length).trim();
549
+
550
+ if (!prompt) {
551
+ ui.notify("Provide a prompt after the interval, e.g., /loop 5m check the deploy", "warning");
552
+ return;
553
+ }
554
+
555
+ try {
556
+ const parsed = parseInterval(interval);
557
+ const trigger: Trigger = { type: "cron", schedule: parsed.cron };
558
+ const entry = store.create(trigger, prompt, { recurring: true });
559
+ triggerSystem.add(entry);
560
+ widget.update();
561
+ ui.notify(`Loop #${entry.id} created: every ${parsed.description} — ${prompt.slice(0, 50)}`, "info");
562
+ } catch (err: any) {
563
+ ui.notify(err.message, "error");
564
+ }
565
+ return;
566
+ }
567
+
568
+ const choice = await ui.select("Loop mode", [
569
+ `Scheduled: "${trimmed.slice(0, 50)}"`,
570
+ `Event-triggered: "${trimmed.slice(0, 50)}"`,
571
+ `Self-paced: "${trimmed.slice(0, 50)}"`,
572
+ ]);
573
+
574
+ if (!choice) return;
575
+ if (choice.startsWith("Event")) return eventLoop(ui, trimmed);
576
+ return scheduleLoop(ui, trimmed);
577
+ },
578
+ });
579
+
580
+ async function scheduleLoop(ui: any, prompt?: string) {
581
+ const p = prompt || await ui.input("Prompt (what should the agent check?)");
582
+ if (!p) return;
583
+
584
+ const interval = await ui.input("Interval (e.g., 5m, 2h, 1d)");
585
+ if (!interval) return;
586
+
587
+ try {
588
+ const parsed = parseInterval(interval);
589
+ const trigger: Trigger = { type: "cron", schedule: parsed.cron };
590
+ const entry = store.create(trigger, p, { recurring: true });
591
+ triggerSystem.add(entry);
592
+ widget.update();
593
+ ui.notify(`Loop #${entry.id} created: every ${parsed.description}`, "info");
594
+ } catch (err: any) {
595
+ ui.notify(err.message, "error");
596
+ }
597
+ }
598
+
599
+ async function eventLoop(ui: any, prompt?: string) {
600
+ const p = prompt || await ui.input("Prompt");
601
+ if (!p) return;
602
+
603
+ const source = await ui.input("Pi event source (e.g., tool_execution_start, before_agent_start)");
604
+ if (!source) return;
605
+
606
+ const trigger: Trigger = { type: "event", source };
607
+ const entry = store.create(trigger, p, { recurring: true });
608
+ triggerSystem.add(entry);
609
+ widget.update();
610
+ ui.notify(`Event loop #${entry.id} created: fires on "${source}"`, "info");
611
+ }
612
+
613
+ async function viewLoops(ui: any) {
614
+ const loops = store.list();
615
+ if (loops.length === 0) {
616
+ await ui.select("No active loops", ["← Back"]);
617
+ return;
618
+ }
619
+
620
+ const choices = loops.map((l: LoopEntry) => {
621
+ const icon = l.status === "active" ? "⟳" : l.status === "paused" ? "⏸" : "✗";
622
+ const triggerDesc = l.trigger.type === "cron" ? `cron: ${l.trigger.schedule}` : l.trigger.type === "event" ? `event: ${l.trigger.source}` : `hybrid: ${l.trigger.cron}`;
623
+ return `${icon} #${l.id} [${l.status}] ${l.prompt.slice(0, 50)} (${triggerDesc})`;
624
+ });
625
+ choices.push("← Back");
626
+
627
+ const selected = await ui.select("Active Loops", choices);
628
+ if (!selected || selected === "← Back") return;
629
+
630
+ const match = selected.match(/#(\d+)/);
631
+ if (match) {
632
+ const entry = store.get(match[1]);
633
+ if (entry) {
634
+ const actions = ["✗ Delete"];
635
+ if (entry.status === "active") actions.unshift("⏸ Pause");
636
+ else if (entry.status === "paused") actions.unshift("▶ Resume");
637
+ actions.push("← Back");
638
+
639
+ const action = await ui.select(
640
+ `#${entry.id}: ${entry.prompt}\nTrigger: ${JSON.stringify(entry.trigger)}`,
641
+ actions,
642
+ );
643
+
644
+ if (action === "✗ Delete") {
645
+ triggerSystem.remove(entry.id);
646
+ store.delete(entry.id);
647
+ widget.update();
648
+ ui.notify(`Loop #${entry.id} deleted`, "info");
649
+ } else if (action === "⏸ Pause") {
650
+ store.update(entry.id, { status: "paused" });
651
+ triggerSystem.remove(entry.id);
652
+ widget.update();
653
+ ui.notify(`Loop #${entry.id} paused`, "info");
654
+ } else if (action === "▶ Resume") {
655
+ store.update(entry.id, { status: "active" });
656
+ triggerSystem.add(entry);
657
+ widget.update();
658
+ ui.notify(`Loop #${entry.id} resumed`, "info");
659
+ }
660
+ }
661
+ }
662
+
663
+ return viewLoops(ui);
664
+ }
665
+
666
+ async function settings(ui: any) {
667
+ const loops = store.list();
668
+ const active = loops.filter(l => l.status === "active").length;
669
+ ui.notify(`${active}/${loops.length} active loops (max 25)`, "info");
670
+ }
671
+ }