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