@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,163 @@
1
+ const UNIT_TO_CRON: Record<string, number> = {
2
+ s: 1,
3
+ m: 60,
4
+ h: 3600,
5
+ d: 86400,
6
+ };
7
+
8
+ const COMMON_INTERVALS: Record<number, string> = {
9
+ 60: "*/1 * * * *",
10
+ 120: "*/2 * * * *",
11
+ 300: "*/5 * * * *",
12
+ 600: "*/10 * * * *",
13
+ 900: "*/15 * * * *",
14
+ 1800: "*/30 * * * *",
15
+ 3600: "0 * * * *",
16
+ 7200: "0 */2 * * *",
17
+ 10800: "0 */3 * * *",
18
+ 14400: "0 */4 * * *",
19
+ 21600: "0 */6 * * *",
20
+ 28800: "0 */8 * * *",
21
+ 43200: "0 */12 * * *",
22
+ 86400: "0 0 * * *",
23
+ };
24
+
25
+ function roundToNearestCommon(seconds: number): { cron: string; description: string } {
26
+ const keys = Object.keys(COMMON_INTERVALS).map(Number).sort((a, b) => a - b);
27
+ let best = keys[0];
28
+ for (const k of keys) {
29
+ if (Math.abs(k - seconds) < Math.abs(best - seconds)) best = k;
30
+ }
31
+
32
+ const mins = best / 60;
33
+ let description: string;
34
+ if (mins < 60) {
35
+ description = `${mins} minute${mins !== 1 ? "s" : ""}`;
36
+ } else {
37
+ const hrs = mins / 60;
38
+ if (hrs % 24 === 0) {
39
+ const days = hrs / 24;
40
+ description = `${days} day${days !== 1 ? "s" : ""}`;
41
+ } else {
42
+ description = `${hrs} hour${hrs !== 1 ? "s" : ""}`;
43
+ }
44
+ }
45
+
46
+ return { cron: COMMON_INTERVALS[best], description };
47
+ }
48
+
49
+ function isFullCron(expr: string): boolean {
50
+ const parts = expr.trim().split(/\s+/);
51
+ return parts.length === 5;
52
+ }
53
+
54
+ export function parseInterval(input: string): { cron: string; description: string } {
55
+ const trimmed = input.trim();
56
+
57
+ if (isFullCron(trimmed)) {
58
+ return { cron: trimmed, description: `cron: ${trimmed}` };
59
+ }
60
+
61
+ const match = trimmed.match(/^(\d+)\s*(s|m|h|d)$/i);
62
+ if (match) {
63
+ const value = parseInt(match[1], 10);
64
+ const unit = match[2].toLowerCase();
65
+ const totalSec = value * (UNIT_TO_CRON[unit] ?? 60);
66
+
67
+ if (totalSec < 60) {
68
+ return { cron: `*/1 * * * *`, description: `${totalSec} seconds (rounded to 1 minute)` };
69
+ }
70
+
71
+ return roundToNearestCommon(totalSec);
72
+ }
73
+
74
+ throw new Error(
75
+ `Cannot parse interval "${input}". Use formats like "5m", "2h", "1d", or a full cron expression.`
76
+ );
77
+ }
78
+
79
+ export function cronToNextFire(cronExpr: string, fromDate: Date = new Date()): Date {
80
+ const parts = cronExpr.trim().split(/\s+/);
81
+ if (parts.length !== 5) throw new Error(`Invalid cron expression: ${cronExpr}`);
82
+
83
+ const [minF, hourF, dayF, monthF, dowF] = parts;
84
+ const now = new Date(fromDate);
85
+ now.setSeconds(0, 0);
86
+
87
+ for (let minutesAdvanced = 1; minutesAdvanced < 525600; minutesAdvanced++) {
88
+ now.setMinutes(now.getMinutes() + 1);
89
+
90
+ if (!cronFieldMatches(minF, now.getMinutes())) continue;
91
+ if (!cronFieldMatches(hourF, now.getHours())) continue;
92
+ if (!cronFieldMatches(dayF, now.getDate())) continue;
93
+ if (!cronFieldMatches(monthF, now.getMonth() + 1)) continue;
94
+ if (!cronFieldMatches(dowF, now.getDay())) continue;
95
+
96
+ return new Date(now);
97
+ }
98
+
99
+ throw new Error(`No matching time found for cron expression: ${cronExpr}`);
100
+ }
101
+
102
+ function cronFieldMatches(field: string, value: number): boolean {
103
+ if (field === "*") return true;
104
+
105
+ const parts = field.split(",");
106
+ for (const part of parts) {
107
+ if (part === "*") return true;
108
+
109
+ if (part.includes("/")) {
110
+ const [range, stepStr] = part.split("/");
111
+ const step = parseInt(stepStr, 10);
112
+ let rangeMin: number;
113
+ let rangeMax: number;
114
+
115
+ if (range === "*") {
116
+ rangeMin = 0;
117
+ rangeMax = 59;
118
+ } else if (range.includes("-")) {
119
+ const [minS, maxS] = range.split("-");
120
+ rangeMin = parseInt(minS, 10);
121
+ rangeMax = parseInt(maxS, 10);
122
+ } else {
123
+ continue;
124
+ }
125
+
126
+ let v = rangeMin;
127
+ while (v <= rangeMax) {
128
+ if (v === value) return true;
129
+ v += step;
130
+ }
131
+ continue;
132
+ }
133
+
134
+ if (part.includes("-")) {
135
+ const [minS, maxS] = part.split("-");
136
+ const min = parseInt(minS, 10);
137
+ const max = parseInt(maxS, 10);
138
+ if (value >= min && value <= max) return true;
139
+ continue;
140
+ }
141
+
142
+ if (parseInt(part, 10) === value) return true;
143
+ }
144
+
145
+ return false;
146
+ }
147
+
148
+ export function computeJitter(taskId: string, recurring: boolean, scheduleMinutes: number): number {
149
+ let hash = 0;
150
+ for (let i = 0; i < taskId.length; i++) {
151
+ hash = ((hash << 5) - hash) + taskId.charCodeAt(i);
152
+ hash |= 0;
153
+ }
154
+ const normalized = Math.abs(hash % 10000) / 10000;
155
+
156
+ if (recurring && scheduleMinutes <= 30) {
157
+ return Math.floor(normalized * (scheduleMinutes / 2) * 60 * 1000);
158
+ }
159
+ if (recurring) {
160
+ return Math.floor(normalized * 30 * 60 * 1000);
161
+ }
162
+ return Math.floor(normalized * 90 * 1000);
163
+ }
@@ -0,0 +1,148 @@
1
+
2
+ import { spawn } from "node:child_process";
3
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
+ import type { MonitorEntry, MonitorProcess } from "./types.js";
5
+
6
+ export class MonitorManager {
7
+ private processes = new Map<string, MonitorProcess>();
8
+ private nextId = 1;
9
+
10
+ constructor(private pi: ExtensionAPI) {}
11
+
12
+ create(command: string, description?: string, timeout = 300000): MonitorEntry {
13
+ const id = String(this.nextId++);
14
+ const entry: MonitorEntry = {
15
+ id,
16
+ command,
17
+ description,
18
+ timeout,
19
+ status: "running",
20
+ startedAt: Date.now(),
21
+ outputLines: 0,
22
+ };
23
+
24
+ const abortController = new AbortController();
25
+ const child = spawn("sh", ["-c", command], {
26
+ stdio: ["ignore", "pipe", "pipe"],
27
+ signal: abortController.signal,
28
+ env: { ...process.env },
29
+ });
30
+
31
+ const bp: MonitorProcess = {
32
+ entry,
33
+ pid: child.pid!,
34
+ proc: child,
35
+ abortController,
36
+ waiters: [],
37
+ };
38
+
39
+ child.stdout?.on("data", (data: Buffer) => {
40
+ const lines = data.toString().split("\n");
41
+ for (const line of lines) {
42
+ if (line.length === 0) continue;
43
+ entry.outputLines++;
44
+ this.pi.events.emit("monitor:output", {
45
+ monitorId: id,
46
+ line,
47
+ timestamp: Date.now(),
48
+ });
49
+ }
50
+ });
51
+
52
+ child.stderr?.on("data", (data: Buffer) => {
53
+ const lines = data.toString().split("\n");
54
+ for (const line of lines) {
55
+ if (line.length === 0) continue;
56
+ entry.outputLines++;
57
+ this.pi.events.emit("monitor:output", {
58
+ monitorId: id,
59
+ line,
60
+ timestamp: Date.now(),
61
+ });
62
+ }
63
+ });
64
+
65
+ const finish = (code: number | null, status: "completed" | "error") => {
66
+ entry.status = status;
67
+ entry.exitCode = code ?? undefined;
68
+ entry.completedAt = Date.now();
69
+ this.pi.events.emit(status === "completed" ? "monitor:done" : "monitor:error", {
70
+ monitorId: id,
71
+ exitCode: code,
72
+ outputLines: entry.outputLines,
73
+ });
74
+ for (const resolve of bp.waiters) resolve();
75
+ bp.waiters = [];
76
+ };
77
+
78
+ child.on("close", (code) => {
79
+ if (entry.status === "running") {
80
+ finish(code, code === 0 ? "completed" : "error");
81
+ }
82
+ });
83
+
84
+ child.on("error", (err) => {
85
+ if (entry.status === "running") {
86
+ entry.status = "error";
87
+ entry.completedAt = Date.now();
88
+ this.pi.events.emit("monitor:error", {
89
+ monitorId: id,
90
+ error: err.message,
91
+ });
92
+ for (const resolve of bp.waiters) resolve();
93
+ bp.waiters = [];
94
+ }
95
+ });
96
+
97
+ if (timeout > 0) {
98
+ setTimeout(() => {
99
+ if (entry.status === "running") {
100
+ this.stop(id);
101
+ }
102
+ }, timeout);
103
+ }
104
+
105
+ this.processes.set(id, bp);
106
+ return entry;
107
+ }
108
+
109
+ get(id: string): MonitorEntry | undefined {
110
+ const bp = this.processes.get(id);
111
+ return bp?.entry;
112
+ }
113
+
114
+ list(): MonitorEntry[] {
115
+ return Array.from(this.processes.values())
116
+ .map(bp => bp.entry)
117
+ .sort((a, b) => Number(a.id) - Number(b.id));
118
+ }
119
+
120
+ async stop(id: string): Promise<boolean> {
121
+ const bp = this.processes.get(id);
122
+ if (!bp || bp.entry.status !== "running") return false;
123
+
124
+ bp.entry.status = "stopped";
125
+ bp.proc.kill("SIGTERM");
126
+
127
+ await new Promise<void>((resolve) => {
128
+ const timer = setTimeout(() => {
129
+ try { bp.proc.kill("SIGKILL"); } catch { /* already dead */ }
130
+ resolve();
131
+ }, 5000);
132
+
133
+ bp.proc.on("close", () => {
134
+ clearTimeout(timer);
135
+ resolve();
136
+ });
137
+ });
138
+
139
+ bp.entry.completedAt = Date.now();
140
+ for (const resolve of bp.waiters) resolve();
141
+ bp.waiters = [];
142
+ return true;
143
+ }
144
+
145
+ getProcess(id: string): MonitorProcess | undefined {
146
+ return this.processes.get(id);
147
+ }
148
+ }
@@ -0,0 +1,104 @@
1
+ import { computeJitter, cronToNextFire } from "./loop-parse.js";
2
+ import type { LoopStore } from "./store.js";
3
+ import type { LoopEntry } from "./types.js";
4
+
5
+ const _MAX_EXPIRY_DAYS = 7;
6
+
7
+ function computeNextFire(entry: LoopEntry): Date {
8
+ if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
9
+ return cronToNextFire(entry.trigger.type === "hybrid" ? entry.trigger.cron : entry.trigger.schedule);
10
+ }
11
+ return new Date(Date.now() + 60000);
12
+ }
13
+
14
+ export class CronScheduler {
15
+ private timers = new Map<string, NodeJS.Timeout>();
16
+ private fireTimes = new Map<string, number>();
17
+
18
+ constructor(
19
+ private store: LoopStore,
20
+ private onFire: (entry: LoopEntry) => void,
21
+ ) {}
22
+
23
+ start(): void {
24
+ for (const entry of this.store.list()) {
25
+ if (entry.status !== "active") continue;
26
+ if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
27
+ this.armTimer(entry);
28
+ }
29
+ }
30
+ }
31
+
32
+ stop(): void {
33
+ for (const [id, timer] of this.timers) {
34
+ clearTimeout(timer);
35
+ this.timers.delete(id);
36
+ this.fireTimes.delete(id);
37
+ }
38
+ }
39
+
40
+ add(entry: LoopEntry): void {
41
+ if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
42
+ this.armTimer(entry);
43
+ }
44
+ }
45
+
46
+ remove(id: string): void {
47
+ const timer = this.timers.get(id);
48
+ if (timer) clearTimeout(timer);
49
+ this.timers.delete(id);
50
+ this.fireTimes.delete(id);
51
+ }
52
+
53
+ nextFire(id: string): number | undefined {
54
+ return this.fireTimes.get(id);
55
+ }
56
+
57
+ private armTimer(entry: LoopEntry): void {
58
+ const _scheduleExpr = entry.trigger.type === "hybrid" ? entry.trigger.cron : (entry.trigger as { schedule: string }).schedule;
59
+
60
+ const nextFire = computeNextFire(entry);
61
+ const jitter = computeJitter(entry.id, entry.recurring, 30);
62
+ const fireTime = nextFire.getTime() + jitter;
63
+ const now = Date.now();
64
+
65
+ if (fireTime > entry.expiresAt) {
66
+ this.store.update(entry.id, { status: "expired" });
67
+ return;
68
+ }
69
+
70
+ const delay = Math.max(0, fireTime - now);
71
+ this.fireTimes.set(entry.id, fireTime);
72
+
73
+ const existing = this.timers.get(entry.id);
74
+ if (existing) clearTimeout(existing);
75
+
76
+ const timer = setTimeout(() => {
77
+ const current = this.store.get(entry.id);
78
+ if (!current || current.status !== "active") {
79
+ this.timers.delete(entry.id);
80
+ this.fireTimes.delete(entry.id);
81
+ return;
82
+ }
83
+
84
+ const now2 = Date.now();
85
+ if (now2 >= current.expiresAt) {
86
+ this.store.update(entry.id, { status: "expired" });
87
+ this.timers.delete(entry.id);
88
+ this.fireTimes.delete(entry.id);
89
+ return;
90
+ }
91
+
92
+ this.onFire(current);
93
+
94
+ if (current.recurring) {
95
+ this.armTimer(current);
96
+ } else {
97
+ this.timers.delete(entry.id);
98
+ this.fireTimes.delete(entry.id);
99
+ }
100
+ }, delay);
101
+
102
+ this.timers.set(entry.id, timer);
103
+ }
104
+ }
package/src/store.ts ADDED
@@ -0,0 +1,189 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, isAbsolute, join } from "node:path";
4
+ import type { LoopEntry, LoopStatus, LoopStoreData, Trigger } from "./types.js";
5
+
6
+ const LOOPS_DIR = join(homedir(), ".pi", "loops");
7
+ const LOCK_RETRY_MS = 50;
8
+ const LOCK_MAX_RETRIES = 100;
9
+ const MAX_LOOPS = 25;
10
+ const MAX_EXPIRY_DAYS = 7;
11
+
12
+ function acquireLock(lockPath: string): void {
13
+ for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
14
+ try {
15
+ writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
16
+ return;
17
+ } catch (e: any) {
18
+ if (e.code === "EEXIST") {
19
+ try {
20
+ const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
21
+ if (pid && !isProcessRunning(pid)) {
22
+ unlinkSync(lockPath);
23
+ continue;
24
+ }
25
+ } catch { /* ignore read errors */ }
26
+ const start = Date.now();
27
+ while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
28
+ continue;
29
+ }
30
+ throw e;
31
+ }
32
+ }
33
+ throw new Error(`Failed to acquire lock: ${lockPath}`);
34
+ }
35
+
36
+ function releaseLock(lockPath: string): void {
37
+ try { unlinkSync(lockPath); } catch { /* ignore */ }
38
+ }
39
+
40
+ function isProcessRunning(pid: number): boolean {
41
+ try { process.kill(pid, 0); return true; } catch { return false; }
42
+ }
43
+
44
+ export class LoopStore {
45
+ private filePath: string | undefined;
46
+ private lockPath: string | undefined;
47
+
48
+ private nextId = 1;
49
+ private loops = new Map<string, LoopEntry>();
50
+
51
+ constructor(listIdOrPath?: string) {
52
+ if (!listIdOrPath) return;
53
+ const isAbsPath = isAbsolute(listIdOrPath);
54
+ const filePath = isAbsPath ? listIdOrPath : join(LOOPS_DIR, `${listIdOrPath}.json`);
55
+ mkdirSync(dirname(filePath), { recursive: true });
56
+ this.filePath = filePath;
57
+ this.lockPath = filePath + ".lock";
58
+ this.load();
59
+ }
60
+
61
+ private load(): void {
62
+ if (!this.filePath) return;
63
+ if (!existsSync(this.filePath)) return;
64
+ try {
65
+ const data: LoopStoreData = JSON.parse(readFileSync(this.filePath, "utf-8"));
66
+ this.nextId = data.nextId;
67
+ this.loops.clear();
68
+ for (const loop of data.loops) {
69
+ this.loops.set(loop.id, loop);
70
+ }
71
+ } catch { /* corrupt file — start fresh */ }
72
+ }
73
+
74
+ private save(): void {
75
+ if (!this.filePath) return;
76
+ const data: LoopStoreData = {
77
+ nextId: this.nextId,
78
+ loops: Array.from(this.loops.values()),
79
+ };
80
+ const tmpPath = this.filePath + ".tmp";
81
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2));
82
+ renameSync(tmpPath, this.filePath);
83
+ }
84
+
85
+ private withLock<T>(fn: () => T): T {
86
+ if (!this.lockPath) return fn();
87
+ acquireLock(this.lockPath);
88
+ try {
89
+ this.load();
90
+ const result = fn();
91
+ this.save();
92
+ return result;
93
+ } finally {
94
+ releaseLock(this.lockPath);
95
+ }
96
+ }
97
+
98
+ create(trigger: Trigger, prompt: string, opts: { recurring: boolean; autoTask?: boolean; selfPaced?: boolean }): LoopEntry {
99
+ return this.withLock(() => {
100
+ if (this.loops.size >= MAX_LOOPS) {
101
+ throw new Error(`Maximum of ${MAX_LOOPS} loops reached. Delete some before creating new ones.`);
102
+ }
103
+ const now = Date.now();
104
+ const entry: LoopEntry = {
105
+ id: String(this.nextId++),
106
+ prompt,
107
+ trigger,
108
+ status: "active",
109
+ recurring: opts.recurring,
110
+ autoTask: opts.autoTask,
111
+ selfPaced: opts.selfPaced,
112
+ createdAt: now,
113
+ updatedAt: now,
114
+ expiresAt: now + MAX_EXPIRY_DAYS * 24 * 60 * 60 * 1000,
115
+ };
116
+ this.loops.set(entry.id, entry);
117
+ return entry;
118
+ });
119
+ }
120
+
121
+ get(id: string): LoopEntry | undefined {
122
+ if (this.filePath) this.load();
123
+ return this.loops.get(id);
124
+ }
125
+
126
+ list(): LoopEntry[] {
127
+ if (this.filePath) this.load();
128
+ return Array.from(this.loops.values()).sort((a, b) => Number(a.id) - Number(b.id));
129
+ }
130
+
131
+ update(id: string, fields: { status?: LoopStatus; trigger?: Trigger; prompt?: string }): { entry: LoopEntry | undefined; changedFields: string[] } {
132
+ return this.withLock(() => {
133
+ const entry = this.loops.get(id);
134
+ if (!entry) return { entry: undefined, changedFields: [] };
135
+
136
+ const changedFields: string[] = [];
137
+ if (fields.status !== undefined) {
138
+ entry.status = fields.status;
139
+ changedFields.push("status");
140
+ }
141
+ if (fields.trigger !== undefined) {
142
+ entry.trigger = fields.trigger;
143
+ changedFields.push("trigger");
144
+ }
145
+ if (fields.prompt !== undefined) {
146
+ entry.prompt = fields.prompt;
147
+ changedFields.push("prompt");
148
+ }
149
+ entry.updatedAt = Date.now();
150
+ return { entry, changedFields };
151
+ });
152
+ }
153
+
154
+ delete(id: string): boolean {
155
+ return this.withLock(() => {
156
+ if (!this.loops.has(id)) return false;
157
+ this.loops.delete(id);
158
+ return true;
159
+ });
160
+ }
161
+
162
+ clearExpired(): number {
163
+ return this.withLock(() => {
164
+ const now = Date.now();
165
+ let count = 0;
166
+ for (const [id, entry] of this.loops) {
167
+ if (now >= entry.expiresAt) {
168
+ this.loops.delete(id);
169
+ count++;
170
+ }
171
+ }
172
+ return count;
173
+ });
174
+ }
175
+
176
+ clearAll(): number {
177
+ return this.withLock(() => {
178
+ const count = this.loops.size;
179
+ this.loops.clear();
180
+ return count;
181
+ });
182
+ }
183
+
184
+ deleteFileIfEmpty(): boolean {
185
+ if (!this.filePath || this.loops.size > 0) return false;
186
+ try { unlinkSync(this.filePath); } catch { /* ignore */ }
187
+ return true;
188
+ }
189
+ }