@trevonistrevon/pi-loop 0.4.3 → 0.4.4
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/AGENTS.md +4 -0
- package/dist/index.js +21 -1
- package/dist/scheduler.d.ts +1 -1
- package/dist/scheduler.js +31 -41
- package/dist/store.d.ts +0 -1
- package/dist/store.js +0 -1
- package/dist/types.d.ts +0 -1
- package/package.json +1 -1
- package/src/index.ts +18 -1
- package/src/scheduler.ts +33 -41
- package/src/store.ts +1 -2
- package/src/types.ts +0 -1
package/AGENTS.md
CHANGED
|
@@ -50,9 +50,13 @@ Three trigger types, all stored as `LoopEntry.trigger`:
|
|
|
50
50
|
- `{ type: "event", source: "tool_execution_start", filter?: "regex:..." | '{"key":"value"}' }` — eventbus-based
|
|
51
51
|
- `{ type: "hybrid", cron: "...", event: { source, filter? }, debounceMs: 30000 }` — both with debounce
|
|
52
52
|
|
|
53
|
+
All cron/hybrid loops are dynamic: they track their next fire time but only deliver on agent idle (`agent_end`/`turn_start`) rather than wall-clock timers.
|
|
54
|
+
|
|
53
55
|
## Re-wake via In-Memory Pending Notifications
|
|
54
56
|
When a loop fires, the scheduler calls `onLoopFire()` which emits `pi.events("loop:fire", ...)`. The extension buffers a pending notification in memory, re-checks whether the wake is still relevant, and only then injects a `pi.sendMessage()` custom message to wake the agent. Do not rely on early queued follow-up user messages for loop delivery; those are not extension-cancelable once handed to pi's queue.
|
|
55
57
|
|
|
58
|
+
All loops are idle-driven. Cron and hybrid loops track their next fire time but only deliver when the agent becomes idle (via `agent_end`/`turn_start`), resetting their timer from the actual delivery point.
|
|
59
|
+
|
|
56
60
|
## Monitor Streaming via PI Events
|
|
57
61
|
Monitor stdout/stderr lines are emitted as `pi.events("monitor:output", { monitorId, line, timestamp })`. Tool consumers subscribe to these events. Completion emits `"monitor:done"` / `"monitor:error"`.
|
|
58
62
|
|
package/dist/index.js
CHANGED
|
@@ -350,6 +350,7 @@ export default function (pi) {
|
|
|
350
350
|
widget.setUICtx(ctx.ui);
|
|
351
351
|
upgradeStoreIfNeeded(ctx);
|
|
352
352
|
widget.update();
|
|
353
|
+
await pumpLoops();
|
|
353
354
|
});
|
|
354
355
|
pi.on("before_agent_start", async (_event, ctx) => {
|
|
355
356
|
_latestCtx = ctx;
|
|
@@ -368,6 +369,7 @@ export default function (pi) {
|
|
|
368
369
|
_latestCtx = ctx;
|
|
369
370
|
widget.setUICtx(ctx.ui);
|
|
370
371
|
await flushPendingNotifications();
|
|
372
|
+
await pumpLoops();
|
|
371
373
|
});
|
|
372
374
|
pi.on("session_shutdown", async () => {
|
|
373
375
|
agentRunning = false;
|
|
@@ -389,6 +391,25 @@ export default function (pi) {
|
|
|
389
391
|
showPersistedLoops(isResume);
|
|
390
392
|
widget.update();
|
|
391
393
|
});
|
|
394
|
+
// ── Dynamic loop pump — fires cron/hybrid loops on idle instead of wall-clock timers ──
|
|
395
|
+
async function pumpLoops() {
|
|
396
|
+
const pendingTasks = new Map();
|
|
397
|
+
for (const entry of store.list()) {
|
|
398
|
+
if (entry.status !== "active")
|
|
399
|
+
continue;
|
|
400
|
+
if (!entry.autoTask)
|
|
401
|
+
continue;
|
|
402
|
+
if (entry.trigger.type !== "cron" && entry.trigger.type !== "hybrid")
|
|
403
|
+
continue;
|
|
404
|
+
const nextFire = scheduler.nextFire(entry.id);
|
|
405
|
+
if (!nextFire || Date.now() < nextFire)
|
|
406
|
+
continue;
|
|
407
|
+
const pending = await hasPendingTasks();
|
|
408
|
+
if (pending <= 0)
|
|
409
|
+
pendingTasks.set(entry.id, true);
|
|
410
|
+
}
|
|
411
|
+
scheduler.pump(Date.now(), (entry) => !pendingTasks.has(entry.id));
|
|
412
|
+
}
|
|
392
413
|
// ── Loop fire handler — queues an in-memory notification, then injects a custom message when delivery is safe ──
|
|
393
414
|
pi.events.on("loop:fire", async (event) => {
|
|
394
415
|
const data = event;
|
|
@@ -497,7 +518,6 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
497
518
|
const entry = store.create(trigger, prompt, {
|
|
498
519
|
recurring: recurring ?? (inferred !== "event"),
|
|
499
520
|
autoTask,
|
|
500
|
-
selfPaced: triggerInput === "self-paced" || prompt.toLowerCase().includes("self-paced"),
|
|
501
521
|
readOnly,
|
|
502
522
|
maxFires,
|
|
503
523
|
});
|
package/dist/scheduler.d.ts
CHANGED
|
@@ -3,7 +3,6 @@ import type { LoopEntry } from "./types.js";
|
|
|
3
3
|
export declare class CronScheduler {
|
|
4
4
|
private store;
|
|
5
5
|
private onFire;
|
|
6
|
-
private timers;
|
|
7
6
|
private fireTimes;
|
|
8
7
|
constructor(store: LoopStore, onFire: (entry: LoopEntry) => void);
|
|
9
8
|
start(): void;
|
|
@@ -12,4 +11,5 @@ export declare class CronScheduler {
|
|
|
12
11
|
remove(id: string): void;
|
|
13
12
|
nextFire(id: string): number | undefined;
|
|
14
13
|
private armTimer;
|
|
14
|
+
pump(now: number, filter?: (entry: LoopEntry) => boolean): void;
|
|
15
15
|
}
|
package/dist/scheduler.js
CHANGED
|
@@ -9,7 +9,6 @@ function computeNextFire(entry) {
|
|
|
9
9
|
export class CronScheduler {
|
|
10
10
|
store;
|
|
11
11
|
onFire;
|
|
12
|
-
timers = new Map();
|
|
13
12
|
fireTimes = new Map();
|
|
14
13
|
constructor(store, onFire) {
|
|
15
14
|
this.store = store;
|
|
@@ -25,11 +24,7 @@ export class CronScheduler {
|
|
|
25
24
|
}
|
|
26
25
|
}
|
|
27
26
|
stop() {
|
|
28
|
-
|
|
29
|
-
clearTimeout(timer);
|
|
30
|
-
this.timers.delete(id);
|
|
31
|
-
this.fireTimes.delete(id);
|
|
32
|
-
}
|
|
27
|
+
this.fireTimes.clear();
|
|
33
28
|
}
|
|
34
29
|
add(entry) {
|
|
35
30
|
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
|
|
@@ -37,10 +32,6 @@ export class CronScheduler {
|
|
|
37
32
|
}
|
|
38
33
|
}
|
|
39
34
|
remove(id) {
|
|
40
|
-
const timer = this.timers.get(id);
|
|
41
|
-
if (timer)
|
|
42
|
-
clearTimeout(timer);
|
|
43
|
-
this.timers.delete(id);
|
|
44
35
|
this.fireTimes.delete(id);
|
|
45
36
|
}
|
|
46
37
|
nextFire(id) {
|
|
@@ -53,46 +44,45 @@ export class CronScheduler {
|
|
|
53
44
|
const minuteStep = minuteField.startsWith("*/") ? parseInt(minuteField.slice(2), 10) || 30 : 30;
|
|
54
45
|
const jitter = computeJitter(entry.id, entry.recurring, minuteStep);
|
|
55
46
|
const fireTime = nextFire.getTime() + jitter;
|
|
56
|
-
const now = Date.now();
|
|
57
47
|
if (fireTime > entry.expiresAt) {
|
|
58
48
|
this.store.delete(entry.id);
|
|
59
49
|
return;
|
|
60
50
|
}
|
|
61
|
-
const delay = Math.max(0, fireTime - now);
|
|
62
51
|
this.fireTimes.set(entry.id, fireTime);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
this.fireTimes.delete(
|
|
71
|
-
|
|
52
|
+
}
|
|
53
|
+
pump(now, filter) {
|
|
54
|
+
for (const [id, fireTime] of this.fireTimes) {
|
|
55
|
+
if (now < fireTime)
|
|
56
|
+
continue;
|
|
57
|
+
const entry = this.store.get(id);
|
|
58
|
+
if (!entry || entry.status !== "active") {
|
|
59
|
+
this.fireTimes.delete(id);
|
|
60
|
+
continue;
|
|
72
61
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
this.
|
|
77
|
-
this.fireTimes.delete(
|
|
78
|
-
|
|
62
|
+
if (filter && !filter(entry))
|
|
63
|
+
continue;
|
|
64
|
+
if (now >= entry.expiresAt) {
|
|
65
|
+
this.store.delete(id);
|
|
66
|
+
this.fireTimes.delete(id);
|
|
67
|
+
continue;
|
|
79
68
|
}
|
|
80
|
-
this.onFire(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
69
|
+
this.onFire(entry);
|
|
70
|
+
const fresh = this.store.get(id);
|
|
71
|
+
if (!fresh) {
|
|
72
|
+
this.fireTimes.delete(id);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (fresh.recurring && fresh.maxFires && (fresh.fireCount ?? 0) >= fresh.maxFires) {
|
|
76
|
+
this.store.delete(id);
|
|
77
|
+
this.fireTimes.delete(id);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (fresh.recurring) {
|
|
81
|
+
this.armTimer(fresh);
|
|
90
82
|
}
|
|
91
83
|
else {
|
|
92
|
-
this.
|
|
93
|
-
this.fireTimes.delete(entry.id);
|
|
84
|
+
this.fireTimes.delete(id);
|
|
94
85
|
}
|
|
95
|
-
}
|
|
96
|
-
this.timers.set(entry.id, timer);
|
|
86
|
+
}
|
|
97
87
|
}
|
|
98
88
|
}
|
package/dist/store.d.ts
CHANGED
package/dist/store.js
CHANGED
package/dist/types.d.ts
CHANGED
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -381,6 +381,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
381
381
|
widget.setUICtx(ctx.ui);
|
|
382
382
|
upgradeStoreIfNeeded(ctx);
|
|
383
383
|
widget.update();
|
|
384
|
+
await pumpLoops();
|
|
384
385
|
});
|
|
385
386
|
|
|
386
387
|
pi.on("before_agent_start", async (_event, ctx) => {
|
|
@@ -402,6 +403,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
402
403
|
_latestCtx = ctx;
|
|
403
404
|
widget.setUICtx(ctx.ui);
|
|
404
405
|
await flushPendingNotifications();
|
|
406
|
+
await pumpLoops();
|
|
405
407
|
});
|
|
406
408
|
|
|
407
409
|
pi.on("session_shutdown", async () => {
|
|
@@ -429,6 +431,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
429
431
|
widget.update();
|
|
430
432
|
});
|
|
431
433
|
|
|
434
|
+
// ── Dynamic loop pump — fires cron/hybrid loops on idle instead of wall-clock timers ──
|
|
435
|
+
|
|
436
|
+
async function pumpLoops(): Promise<void> {
|
|
437
|
+
const pendingTasks = new Map<string, boolean>();
|
|
438
|
+
for (const entry of store.list()) {
|
|
439
|
+
if (entry.status !== "active") continue;
|
|
440
|
+
if (!entry.autoTask) continue;
|
|
441
|
+
if (entry.trigger.type !== "cron" && entry.trigger.type !== "hybrid") continue;
|
|
442
|
+
const nextFire = scheduler.nextFire(entry.id);
|
|
443
|
+
if (!nextFire || Date.now() < nextFire) continue;
|
|
444
|
+
const pending = await hasPendingTasks();
|
|
445
|
+
if (pending <= 0) pendingTasks.set(entry.id, true);
|
|
446
|
+
}
|
|
447
|
+
scheduler.pump(Date.now(), (entry) => !pendingTasks.has(entry.id));
|
|
448
|
+
}
|
|
449
|
+
|
|
432
450
|
// ── Loop fire handler — queues an in-memory notification, then injects a custom message when delivery is safe ──
|
|
433
451
|
|
|
434
452
|
pi.events.on("loop:fire", async (event: unknown) => {
|
|
@@ -544,7 +562,6 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
544
562
|
const entry = store.create(trigger, prompt, {
|
|
545
563
|
recurring: recurring ?? (inferred !== "event"),
|
|
546
564
|
autoTask,
|
|
547
|
-
selfPaced: triggerInput === "self-paced" || prompt.toLowerCase().includes("self-paced"),
|
|
548
565
|
readOnly,
|
|
549
566
|
maxFires,
|
|
550
567
|
});
|
package/src/scheduler.ts
CHANGED
|
@@ -12,7 +12,6 @@ function computeNextFire(entry: LoopEntry): Date {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export class CronScheduler {
|
|
15
|
-
private timers = new Map<string, NodeJS.Timeout>();
|
|
16
15
|
private fireTimes = new Map<string, number>();
|
|
17
16
|
|
|
18
17
|
constructor(
|
|
@@ -30,11 +29,7 @@ export class CronScheduler {
|
|
|
30
29
|
}
|
|
31
30
|
|
|
32
31
|
stop(): void {
|
|
33
|
-
|
|
34
|
-
clearTimeout(timer);
|
|
35
|
-
this.timers.delete(id);
|
|
36
|
-
this.fireTimes.delete(id);
|
|
37
|
-
}
|
|
32
|
+
this.fireTimes.clear();
|
|
38
33
|
}
|
|
39
34
|
|
|
40
35
|
add(entry: LoopEntry): void {
|
|
@@ -44,9 +39,6 @@ export class CronScheduler {
|
|
|
44
39
|
}
|
|
45
40
|
|
|
46
41
|
remove(id: string): void {
|
|
47
|
-
const timer = this.timers.get(id);
|
|
48
|
-
if (timer) clearTimeout(timer);
|
|
49
|
-
this.timers.delete(id);
|
|
50
42
|
this.fireTimes.delete(id);
|
|
51
43
|
}
|
|
52
44
|
|
|
@@ -62,52 +54,52 @@ export class CronScheduler {
|
|
|
62
54
|
const minuteStep = minuteField.startsWith("*/") ? parseInt(minuteField.slice(2), 10) || 30 : 30;
|
|
63
55
|
const jitter = computeJitter(entry.id, entry.recurring, minuteStep);
|
|
64
56
|
const fireTime = nextFire.getTime() + jitter;
|
|
65
|
-
const now = Date.now();
|
|
66
57
|
|
|
67
58
|
if (fireTime > entry.expiresAt) {
|
|
68
59
|
this.store.delete(entry.id);
|
|
69
60
|
return;
|
|
70
61
|
}
|
|
71
62
|
|
|
72
|
-
const delay = Math.max(0, fireTime - now);
|
|
73
63
|
this.fireTimes.set(entry.id, fireTime);
|
|
64
|
+
}
|
|
74
65
|
|
|
75
|
-
|
|
76
|
-
|
|
66
|
+
pump(now: number, filter?: (entry: LoopEntry) => boolean): void {
|
|
67
|
+
for (const [id, fireTime] of this.fireTimes) {
|
|
68
|
+
if (now < fireTime) continue;
|
|
77
69
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
this.fireTimes.delete(entry.id);
|
|
83
|
-
return;
|
|
70
|
+
const entry = this.store.get(id);
|
|
71
|
+
if (!entry || entry.status !== "active") {
|
|
72
|
+
this.fireTimes.delete(id);
|
|
73
|
+
continue;
|
|
84
74
|
}
|
|
85
75
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
this.
|
|
90
|
-
this.fireTimes.delete(
|
|
91
|
-
|
|
76
|
+
if (filter && !filter(entry)) continue;
|
|
77
|
+
|
|
78
|
+
if (now >= entry.expiresAt) {
|
|
79
|
+
this.store.delete(id);
|
|
80
|
+
this.fireTimes.delete(id);
|
|
81
|
+
continue;
|
|
92
82
|
}
|
|
93
83
|
|
|
94
|
-
this.onFire(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
this.timers.delete(entry.id);
|
|
101
|
-
this.fireTimes.delete(entry.id);
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
this.armTimer(current);
|
|
105
|
-
} else {
|
|
106
|
-
this.timers.delete(entry.id);
|
|
107
|
-
this.fireTimes.delete(entry.id);
|
|
84
|
+
this.onFire(entry);
|
|
85
|
+
|
|
86
|
+
const fresh = this.store.get(id);
|
|
87
|
+
if (!fresh) {
|
|
88
|
+
this.fireTimes.delete(id);
|
|
89
|
+
continue;
|
|
108
90
|
}
|
|
109
|
-
}, delay);
|
|
110
91
|
|
|
111
|
-
|
|
92
|
+
if (fresh.recurring && fresh.maxFires && (fresh.fireCount ?? 0) >= fresh.maxFires) {
|
|
93
|
+
this.store.delete(id);
|
|
94
|
+
this.fireTimes.delete(id);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (fresh.recurring) {
|
|
99
|
+
this.armTimer(fresh);
|
|
100
|
+
} else {
|
|
101
|
+
this.fireTimes.delete(id);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
112
104
|
}
|
|
113
105
|
}
|
package/src/store.ts
CHANGED
|
@@ -95,7 +95,7 @@ export class LoopStore {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
create(trigger: Trigger, prompt: string, opts: { recurring: boolean; autoTask?: boolean;
|
|
98
|
+
create(trigger: Trigger, prompt: string, opts: { recurring: boolean; autoTask?: boolean; readOnly?: boolean; maxFires?: number }): LoopEntry {
|
|
99
99
|
return this.withLock(() => {
|
|
100
100
|
if (this.loops.size >= MAX_LOOPS) {
|
|
101
101
|
throw new Error(`Maximum of ${MAX_LOOPS} loops reached. Delete some before creating new ones.`);
|
|
@@ -108,7 +108,6 @@ export class LoopStore {
|
|
|
108
108
|
status: "active",
|
|
109
109
|
recurring: opts.recurring,
|
|
110
110
|
autoTask: opts.autoTask,
|
|
111
|
-
selfPaced: opts.selfPaced,
|
|
112
111
|
readOnly: opts.readOnly,
|
|
113
112
|
maxFires: opts.maxFires,
|
|
114
113
|
fireCount: 0,
|