@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.
@@ -0,0 +1,132 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { CronScheduler } from "./scheduler.js";
3
+ import type { LoopEntry } from "./types.js";
4
+
5
+ export class TriggerSystem {
6
+ private eventSubscriptions = new Map<string, Map<string, () => void>>();
7
+ private hybridTimers = new Map<string, NodeJS.Timeout>();
8
+ private lastFireTime = new Map<string, number>();
9
+
10
+ constructor(
11
+ private pi: ExtensionAPI,
12
+ private scheduler: CronScheduler,
13
+ ) {}
14
+
15
+ start(): void {
16
+ this.scheduler.start();
17
+ }
18
+
19
+ stop(): void {
20
+ this.scheduler.stop();
21
+ for (const [_source, subs] of this.eventSubscriptions) {
22
+ for (const unsub of subs.values()) unsub();
23
+ }
24
+ this.eventSubscriptions.clear();
25
+ for (const timer of this.hybridTimers.values()) clearTimeout(timer);
26
+ this.hybridTimers.clear();
27
+ }
28
+
29
+ add(entry: LoopEntry): void {
30
+ if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
31
+ this.scheduler.add(entry);
32
+ }
33
+ if (entry.trigger.type === "event" || entry.trigger.type === "hybrid") {
34
+ const ev = entry.trigger.type === "hybrid" ? entry.trigger.event : entry.trigger;
35
+ this.subscribeEvent(entry, ev.source, ev.filter);
36
+ }
37
+ }
38
+
39
+ remove(id: string): void {
40
+ this.scheduler.remove(id);
41
+ for (const [source, subs] of this.eventSubscriptions) {
42
+ const unsub = subs.get(id);
43
+ if (unsub) { unsub(); subs.delete(id); }
44
+ if (subs.size === 0) this.eventSubscriptions.delete(source);
45
+ }
46
+ const timer = this.hybridTimers.get(id);
47
+ if (timer) { clearTimeout(timer); this.hybridTimers.delete(id); }
48
+ this.lastFireTime.delete(id);
49
+ }
50
+
51
+ private subscribeEvent(entry: LoopEntry, source: string, filter?: string): void {
52
+ if (!this.eventSubscriptions.has(source)) {
53
+ this.eventSubscriptions.set(source, new Map());
54
+ }
55
+ const subs = this.eventSubscriptions.get(source)!;
56
+
57
+ const unsub = this.pi.events.on(source as any, (data: any) => {
58
+ if (entry.trigger.type === "hybrid") {
59
+ this.handleHybridFire(entry, data);
60
+ } else {
61
+ if (this.matchesFilter(data, filter)) {
62
+ this.fireLoop(entry);
63
+ }
64
+ }
65
+ });
66
+
67
+ subs.set(entry.id, unsub);
68
+ }
69
+
70
+ private handleHybridFire(entry: LoopEntry, _data: any): void {
71
+ const now = Date.now();
72
+ const last = this.lastFireTime.get(entry.id) ?? 0;
73
+ const debounceMs = entry.trigger.type === "hybrid" ? entry.trigger.debounceMs : 0;
74
+
75
+ if (now - last < debounceMs) {
76
+ const existing = this.hybridTimers.get(entry.id);
77
+ if (existing) clearTimeout(existing);
78
+ }
79
+
80
+ const remaining = debounceMs - (now - last);
81
+ if (remaining <= 0) {
82
+ this.fireLoop(entry);
83
+ return;
84
+ }
85
+
86
+ const timer = setTimeout(() => {
87
+ this.hybridTimers.delete(entry.id);
88
+ this.fireLoop(entry);
89
+ }, remaining);
90
+
91
+ this.hybridTimers.set(entry.id, timer);
92
+ }
93
+
94
+ private fireLoop(entry: LoopEntry): void {
95
+ this.lastFireTime.set(entry.id, Date.now());
96
+ this.pi.events.emit("loop:fire", {
97
+ loopId: entry.id,
98
+ prompt: entry.prompt,
99
+ trigger: entry.trigger,
100
+ timestamp: Date.now(),
101
+ });
102
+ }
103
+
104
+ private matchesFilter(data: any, filter?: string): boolean {
105
+ if (!filter) return true;
106
+
107
+ if (filter.startsWith("regex:")) {
108
+ try {
109
+ const regex = new RegExp(filter.slice(6));
110
+ return regex.test(JSON.stringify(data));
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ try {
117
+ const parsed = JSON.parse(filter);
118
+ for (const [key, value] of Object.entries(parsed)) {
119
+ const dataValue = data?.[key];
120
+ if (dataValue === undefined) return false;
121
+ if (typeof value === "object" && typeof dataValue === "object") {
122
+ if (JSON.stringify(value) !== JSON.stringify(dataValue)) return false;
123
+ } else if (String(dataValue) !== String(value)) {
124
+ return false;
125
+ }
126
+ }
127
+ return true;
128
+ } catch {
129
+ return true;
130
+ }
131
+ }
132
+ }
package/src/types.ts ADDED
@@ -0,0 +1,59 @@
1
+ export type LoopStatus = "active" | "paused" | "expired";
2
+
3
+ export interface CronTrigger {
4
+ type: "cron";
5
+ schedule: string;
6
+ }
7
+
8
+ export interface EventTrigger {
9
+ type: "event";
10
+ source: string;
11
+ filter?: string;
12
+ }
13
+
14
+ export interface HybridTrigger {
15
+ type: "hybrid";
16
+ cron: string;
17
+ event: { source: string; filter?: string };
18
+ debounceMs: number;
19
+ }
20
+
21
+ export type Trigger = CronTrigger | EventTrigger | HybridTrigger;
22
+
23
+ export interface LoopEntry {
24
+ id: string;
25
+ prompt: string;
26
+ trigger: Trigger;
27
+ status: LoopStatus;
28
+ recurring: boolean;
29
+ createdAt: number;
30
+ updatedAt: number;
31
+ expiresAt: number;
32
+ autoTask?: boolean;
33
+ selfPaced?: boolean;
34
+ }
35
+
36
+ export interface LoopStoreData {
37
+ nextId: number;
38
+ loops: LoopEntry[];
39
+ }
40
+
41
+ export interface MonitorEntry {
42
+ id: string;
43
+ command: string;
44
+ description?: string;
45
+ timeout: number;
46
+ status: "running" | "completed" | "error" | "stopped";
47
+ startedAt: number;
48
+ completedAt?: number;
49
+ exitCode?: number;
50
+ outputLines: number;
51
+ }
52
+
53
+ export interface MonitorProcess {
54
+ entry: MonitorEntry;
55
+ pid: number;
56
+ proc: import("node:child_process").ChildProcess;
57
+ abortController: AbortController;
58
+ waiters: Array<() => void>;
59
+ }
@@ -0,0 +1,132 @@
1
+ import { truncateToWidth } from "@mariozechner/pi-tui";
2
+ import type { MonitorManager } from "../monitor-manager.js";
3
+ import type { CronScheduler } from "../scheduler.js";
4
+ import type { LoopStore } from "../store.js";
5
+
6
+ const MAX_VISIBLE = 6;
7
+
8
+ export type UICtx = {
9
+ setStatus(key: string, text: string | undefined): void;
10
+ setWidget(
11
+ key: string,
12
+ content: undefined | ((tui: any, theme: any) => { render(): string[]; invalidate(): void }),
13
+ options?: { placement?: "aboveEditor" | "belowEditor" },
14
+ ): void;
15
+ };
16
+
17
+ export class LoopWidget {
18
+ private uiCtx: UICtx | undefined;
19
+ private tui: any | undefined;
20
+ private widgetRegistered = false;
21
+ private interval: ReturnType<typeof setInterval> | undefined;
22
+
23
+ constructor(
24
+ private store: LoopStore,
25
+ private scheduler: CronScheduler | undefined,
26
+ private monitorManager: MonitorManager,
27
+ ) {}
28
+
29
+ setUICtx(ctx: UICtx) {
30
+ this.uiCtx = ctx;
31
+ }
32
+
33
+ setStore(store: LoopStore) {
34
+ this.store = store;
35
+ }
36
+
37
+ setScheduler(scheduler: CronScheduler) {
38
+ this.scheduler = scheduler;
39
+ }
40
+
41
+ update() {
42
+ if (!this.uiCtx) return;
43
+
44
+ const loops = this.store.list().filter(l => l.status === "active");
45
+ const monitors = this.monitorManager.list().filter(m => m.status === "running");
46
+
47
+ if (loops.length === 0 && monitors.length === 0) {
48
+ if (this.widgetRegistered) {
49
+ this.uiCtx.setWidget("loops", undefined);
50
+ this.widgetRegistered = false;
51
+ }
52
+ if (this.interval) {
53
+ clearInterval(this.interval);
54
+ this.interval = undefined;
55
+ }
56
+ return;
57
+ }
58
+
59
+ if (!this.interval) {
60
+ this.interval = setInterval(() => this.update(), 5000);
61
+ }
62
+
63
+ if (!this.widgetRegistered) {
64
+ this.uiCtx.setWidget("loops", (tui, theme) => {
65
+ this.tui = tui;
66
+ return { render: () => this.renderWidget(tui, theme), invalidate: () => {} };
67
+ }, { placement: "aboveEditor" });
68
+ this.widgetRegistered = true;
69
+ } else if (this.tui) {
70
+ this.tui.requestRender();
71
+ }
72
+ }
73
+
74
+ private renderWidget(tui: any, _theme: any): string[] {
75
+ const loops = this.store.list().filter(l => l.status === "active");
76
+ const monitors = this.monitorManager.list().filter(m => m.status === "running");
77
+ const w = tui.terminal.columns;
78
+ const trunc = (line: string) => truncateToWidth(line, w);
79
+
80
+ const lines: string[] = [];
81
+ const total = loops.length + monitors.length;
82
+
83
+ if (total === 0) return [];
84
+
85
+ lines.push(trunc(`⟳ ${loops.length} loops · ${monitors.length} monitors`));
86
+
87
+ for (const loop of loops.slice(0, MAX_VISIBLE)) {
88
+ const icon = "◷";
89
+ let schedule = "";
90
+ if (loop.trigger.type === "cron") {
91
+ schedule = loop.trigger.schedule;
92
+ } else if (loop.trigger.type === "event") {
93
+ schedule = `event: ${loop.trigger.source}`;
94
+ } else if (loop.trigger.type === "hybrid") {
95
+ schedule = `hybrid: ${loop.trigger.cron}`;
96
+ }
97
+ const nextFire = this.scheduler?.nextFire(loop.id);
98
+ let timing = "";
99
+ if (nextFire) {
100
+ const remaining = Math.max(0, nextFire - Date.now());
101
+ timing = ` (next: ${formatDuration(remaining)})`;
102
+ }
103
+ lines.push(trunc(` ${icon} #${loop.id} ${loop.prompt.slice(0, 50)} → ${schedule}${timing}`));
104
+ }
105
+
106
+ for (const m of monitors.slice(0, Math.max(0, MAX_VISIBLE - loops.length))) {
107
+ const icon = "◉";
108
+ const age = Date.now() - m.startedAt;
109
+ lines.push(trunc(` ${icon} #${m.id} ${m.command.slice(0, 40)} ${m.outputLines} lines (${formatDuration(age)})`));
110
+ }
111
+
112
+ return lines;
113
+ }
114
+
115
+ dispose() {
116
+ if (this.interval) { clearInterval(this.interval); this.interval = undefined; }
117
+ if (this.uiCtx) this.uiCtx.setWidget("loops", undefined);
118
+ this.widgetRegistered = false;
119
+ this.tui = undefined;
120
+ }
121
+ }
122
+
123
+ function formatDuration(ms: number): string {
124
+ const totalSec = Math.floor(ms / 1000);
125
+ if (totalSec < 60) return `${totalSec}s`;
126
+ const min = Math.floor(totalSec / 60);
127
+ const sec = totalSec % 60;
128
+ if (min < 60) return sec > 0 ? `${min}m ${sec}s` : `${min}m`;
129
+ const hr = Math.floor(min / 60);
130
+ const remMin = min % 60;
131
+ return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`;
132
+ }