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