@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 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
  });
@@ -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
- for (const [id, timer] of this.timers) {
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
- const existing = this.timers.get(entry.id);
64
- if (existing)
65
- clearTimeout(existing);
66
- const timer = setTimeout(() => {
67
- const current = this.store.get(entry.id);
68
- if (!current || current.status !== "active") {
69
- this.timers.delete(entry.id);
70
- this.fireTimes.delete(entry.id);
71
- return;
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
- const now2 = Date.now();
74
- if (now2 >= current.expiresAt) {
75
- this.store.delete(entry.id);
76
- this.timers.delete(entry.id);
77
- this.fireTimes.delete(entry.id);
78
- return;
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(current);
81
- if (current.recurring) {
82
- const fresh = this.store.get(entry.id);
83
- if (fresh?.maxFires && (fresh.fireCount ?? 0) >= fresh.maxFires) {
84
- this.store.delete(entry.id);
85
- this.timers.delete(entry.id);
86
- this.fireTimes.delete(entry.id);
87
- return;
88
- }
89
- this.armTimer(current);
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.timers.delete(entry.id);
93
- this.fireTimes.delete(entry.id);
84
+ this.fireTimes.delete(id);
94
85
  }
95
- }, delay);
96
- this.timers.set(entry.id, timer);
86
+ }
97
87
  }
98
88
  }
package/dist/store.d.ts CHANGED
@@ -11,7 +11,6 @@ export declare class LoopStore {
11
11
  create(trigger: Trigger, prompt: string, opts: {
12
12
  recurring: boolean;
13
13
  autoTask?: boolean;
14
- selfPaced?: boolean;
15
14
  readOnly?: boolean;
16
15
  maxFires?: number;
17
16
  }): LoopEntry;
package/dist/store.js CHANGED
@@ -117,7 +117,6 @@ export class LoopStore {
117
117
  status: "active",
118
118
  recurring: opts.recurring,
119
119
  autoTask: opts.autoTask,
120
- selfPaced: opts.selfPaced,
121
120
  readOnly: opts.readOnly,
122
121
  maxFires: opts.maxFires,
123
122
  fireCount: 0,
package/dist/types.d.ts CHANGED
@@ -28,7 +28,6 @@ export interface LoopEntry {
28
28
  updatedAt: number;
29
29
  expiresAt: number;
30
30
  autoTask?: boolean;
31
- selfPaced?: boolean;
32
31
  readOnly?: boolean;
33
32
  maxFires?: number;
34
33
  fireCount?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trevonistrevon/pi-loop",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "A pi extension for cron/event-based agent re-wake loops and background process monitoring.",
5
5
  "author": "trevonistrevon",
6
6
  "license": "MIT",
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
- for (const [id, timer] of this.timers) {
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
- const existing = this.timers.get(entry.id);
76
- if (existing) clearTimeout(existing);
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
- const timer = setTimeout(() => {
79
- const current = this.store.get(entry.id);
80
- if (!current || current.status !== "active") {
81
- this.timers.delete(entry.id);
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
- const now2 = Date.now();
87
- if (now2 >= current.expiresAt) {
88
- this.store.delete(entry.id);
89
- this.timers.delete(entry.id);
90
- this.fireTimes.delete(entry.id);
91
- return;
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(current);
95
-
96
- if (current.recurring) {
97
- const fresh = this.store.get(entry.id);
98
- if (fresh?.maxFires && (fresh.fireCount ?? 0) >= fresh.maxFires) {
99
- this.store.delete(entry.id);
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
- this.timers.set(entry.id, timer);
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; selfPaced?: boolean; readOnly?: boolean; maxFires?: number }): LoopEntry {
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,
package/src/types.ts CHANGED
@@ -30,7 +30,6 @@ export interface LoopEntry {
30
30
  updatedAt: number;
31
31
  expiresAt: number;
32
32
  autoTask?: boolean;
33
- selfPaced?: boolean;
34
33
  readOnly?: boolean;
35
34
  maxFires?: number;
36
35
  fireCount?: number;