@trevonistrevon/pi-loop 0.4.2 → 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
@@ -209,6 +209,35 @@ export default function (pi) {
209
209
  message: buildLoopFireMessage(data),
210
210
  };
211
211
  }
212
+ function triggerHasEventSource(trigger, source) {
213
+ if (typeof trigger === "string")
214
+ return false;
215
+ return trigger.type === "event"
216
+ ? trigger.source === source
217
+ : trigger.type === "hybrid"
218
+ ? trigger.event.source === source
219
+ : false;
220
+ }
221
+ async function maybeBootstrapTaskLoop(entry) {
222
+ if (!entry.recurring)
223
+ return false;
224
+ if (!triggerHasEventSource(entry.trigger, "tasks:created"))
225
+ return false;
226
+ const pending = await hasPendingTasks();
227
+ if (pending <= 0)
228
+ return false;
229
+ debug(`loop #${entry.id} — bootstrapping existing pending tasks (${pending})`);
230
+ await queueOrDeliverNotification({
231
+ loopId: entry.id,
232
+ prompt: entry.prompt,
233
+ trigger: entry.trigger,
234
+ timestamp: Date.now(),
235
+ readOnly: entry.readOnly,
236
+ recurring: false,
237
+ autoTask: true,
238
+ });
239
+ return true;
240
+ }
212
241
  async function deliverNotification(notification) {
213
242
  if (notification.autoTask) {
214
243
  const pending = await hasPendingTasks();
@@ -321,6 +350,7 @@ export default function (pi) {
321
350
  widget.setUICtx(ctx.ui);
322
351
  upgradeStoreIfNeeded(ctx);
323
352
  widget.update();
353
+ await pumpLoops();
324
354
  });
325
355
  pi.on("before_agent_start", async (_event, ctx) => {
326
356
  _latestCtx = ctx;
@@ -339,6 +369,7 @@ export default function (pi) {
339
369
  _latestCtx = ctx;
340
370
  widget.setUICtx(ctx.ui);
341
371
  await flushPendingNotifications();
372
+ await pumpLoops();
342
373
  });
343
374
  pi.on("session_shutdown", async () => {
344
375
  agentRunning = false;
@@ -360,6 +391,25 @@ export default function (pi) {
360
391
  showPersistedLoops(isResume);
361
392
  widget.update();
362
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
+ }
363
413
  // ── Loop fire handler — queues an in-memory notification, then injects a custom message when delivery is safe ──
364
414
  pi.events.on("loop:fire", async (event) => {
365
415
  const data = event;
@@ -425,8 +475,9 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
425
475
  "## readOnly mode",
426
476
  "Set readOnly: true for loops that only observe and report (checks, status polls). This prevents unintended changes.",
427
477
  "## Task-driven workflows",
428
- "After creating tasks with TaskCreate, use an event loop with autoTask: true so the system checks for pending tasks before firing: LoopCreate trigger='tasks:created' triggerType='event' autoTask: true maxFires: 30 prompt='Run TaskList, pick the next available pending task, work on it.'",
429
- "When no tasks are pending, the loop skips the wake entirely no tokens burned on empty polls.",
478
+ "Do not rely on a past 'tasks:created' event to replay. If tasks already exist, bootstrap the first pass in the current turn or use a hybrid/event loop that can catch future task creation and a cron safety-net.",
479
+ "Use autoTask only when you want the loop itself to create a task on each fire. For processing an existing task backlog, leave autoTask off and have the loop run TaskList to pick the next pending task.",
480
+ "When no tasks are pending, the loop should stop itself or skip the wake entirely — no tokens burned on empty polls.",
430
481
  "After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
431
482
  ],
432
483
  parameters: Type.Object({
@@ -439,7 +490,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
439
490
  readOnly: Type.Optional(Type.Boolean({ description: "Restrict the agent to read-only tools when this loop fires (default: false)", default: false })),
440
491
  maxFires: Type.Optional(Type.Number({ description: "Auto-stop after N fires. Prevents infinite token burn on polling loops." })),
441
492
  }),
442
- execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
493
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
443
494
  const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs, readOnly, maxFires } = params;
444
495
  let trigger;
445
496
  const inferred = triggerType ?? inferTriggerType(triggerInput);
@@ -467,7 +518,6 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
467
518
  const entry = store.create(trigger, prompt, {
468
519
  recurring: recurring ?? (inferred !== "event"),
469
520
  autoTask,
470
- selfPaced: triggerInput === "self-paced" || prompt.toLowerCase().includes("self-paced"),
471
521
  readOnly,
472
522
  maxFires,
473
523
  });
@@ -487,6 +537,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
487
537
  }
488
538
  catch { /* filter parse failure, ignore */ }
489
539
  }
540
+ const bootstrapped = await maybeBootstrapTaskLoop(entry);
490
541
  widget.update();
491
542
  const triggerDesc = trigger.type === "cron"
492
543
  ? `schedule: ${trigger.schedule}`
@@ -497,6 +548,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
497
548
  `Trigger: ${triggerDesc}\n` +
498
549
  `Recurring: ${entry.recurring}\n` +
499
550
  (entry.autoTask ? `Auto-task: enabled\n` : "") +
551
+ (bootstrapped ? "Bootstrap: queued initial wake for existing pending tasks\n" : "") +
500
552
  ((tasksAvailable || nativeTasksRegistered) ? "" : "(task system not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available)\n") +
501
553
  `ID: ${entry.id} (use LoopDelete to cancel)`));
502
554
  },
@@ -956,6 +1008,12 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
956
1008
  }
957
1009
  if (trimmed) {
958
1010
  const entry = nativeTaskStore.create(trimmed.slice(0, 80), trimmed);
1011
+ pi.events.emit("tasks:created", {
1012
+ taskId: entry.id,
1013
+ subject: entry.subject,
1014
+ description: entry.description,
1015
+ status: entry.status,
1016
+ });
959
1017
  widget.update();
960
1018
  ctx.ui.notify(`Task #${entry.id} created`, "info");
961
1019
  return;
@@ -982,6 +1040,12 @@ Fields:
982
1040
  }),
983
1041
  execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
984
1042
  const entry = taskStore.create(params.subject, params.description);
1043
+ pi.events.emit("tasks:created", {
1044
+ taskId: entry.id,
1045
+ subject: entry.subject,
1046
+ description: entry.description,
1047
+ status: entry.status,
1048
+ });
985
1049
  widget.update();
986
1050
  return Promise.resolve(textResult(`Task #${entry.id} created: ${entry.subject}`));
987
1051
  },
@@ -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.2",
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
@@ -229,6 +229,35 @@ export default function (pi: ExtensionAPI) {
229
229
  };
230
230
  }
231
231
 
232
+ function triggerHasEventSource(trigger: Trigger | string, source: string): boolean {
233
+ if (typeof trigger === "string") return false;
234
+ return trigger.type === "event"
235
+ ? trigger.source === source
236
+ : trigger.type === "hybrid"
237
+ ? trigger.event.source === source
238
+ : false;
239
+ }
240
+
241
+ async function maybeBootstrapTaskLoop(entry: LoopEntry): Promise<boolean> {
242
+ if (!entry.recurring) return false;
243
+ if (!triggerHasEventSource(entry.trigger, "tasks:created")) return false;
244
+
245
+ const pending = await hasPendingTasks();
246
+ if (pending <= 0) return false;
247
+
248
+ debug(`loop #${entry.id} — bootstrapping existing pending tasks (${pending})`);
249
+ await queueOrDeliverNotification({
250
+ loopId: entry.id,
251
+ prompt: entry.prompt,
252
+ trigger: entry.trigger,
253
+ timestamp: Date.now(),
254
+ readOnly: entry.readOnly,
255
+ recurring: false,
256
+ autoTask: true,
257
+ });
258
+ return true;
259
+ }
260
+
232
261
  async function deliverNotification(notification: PendingNotification): Promise<boolean> {
233
262
  if (notification.autoTask) {
234
263
  const pending = await hasPendingTasks();
@@ -352,6 +381,7 @@ export default function (pi: ExtensionAPI) {
352
381
  widget.setUICtx(ctx.ui);
353
382
  upgradeStoreIfNeeded(ctx);
354
383
  widget.update();
384
+ await pumpLoops();
355
385
  });
356
386
 
357
387
  pi.on("before_agent_start", async (_event, ctx) => {
@@ -373,6 +403,7 @@ export default function (pi: ExtensionAPI) {
373
403
  _latestCtx = ctx;
374
404
  widget.setUICtx(ctx.ui);
375
405
  await flushPendingNotifications();
406
+ await pumpLoops();
376
407
  });
377
408
 
378
409
  pi.on("session_shutdown", async () => {
@@ -400,6 +431,22 @@ export default function (pi: ExtensionAPI) {
400
431
  widget.update();
401
432
  });
402
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
+
403
450
  // ── Loop fire handler — queues an in-memory notification, then injects a custom message when delivery is safe ──
404
451
 
405
452
  pi.events.on("loop:fire", async (event: unknown) => {
@@ -470,8 +517,9 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
470
517
  "## readOnly mode",
471
518
  "Set readOnly: true for loops that only observe and report (checks, status polls). This prevents unintended changes.",
472
519
  "## Task-driven workflows",
473
- "After creating tasks with TaskCreate, use an event loop with autoTask: true so the system checks for pending tasks before firing: LoopCreate trigger='tasks:created' triggerType='event' autoTask: true maxFires: 30 prompt='Run TaskList, pick the next available pending task, work on it.'",
474
- "When no tasks are pending, the loop skips the wake entirely no tokens burned on empty polls.",
520
+ "Do not rely on a past 'tasks:created' event to replay. If tasks already exist, bootstrap the first pass in the current turn or use a hybrid/event loop that can catch future task creation and a cron safety-net.",
521
+ "Use autoTask only when you want the loop itself to create a task on each fire. For processing an existing task backlog, leave autoTask off and have the loop run TaskList to pick the next pending task.",
522
+ "When no tasks are pending, the loop should stop itself or skip the wake entirely — no tokens burned on empty polls.",
475
523
  "After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
476
524
  ],
477
525
  parameters: Type.Object({
@@ -485,7 +533,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
485
533
  maxFires: Type.Optional(Type.Number({ description: "Auto-stop after N fires. Prevents infinite token burn on polling loops." })),
486
534
  }),
487
535
 
488
- execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
536
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
489
537
  const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs, readOnly, maxFires } = params;
490
538
 
491
539
  let trigger: Trigger;
@@ -514,7 +562,6 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
514
562
  const entry = store.create(trigger, prompt, {
515
563
  recurring: recurring ?? (inferred !== "event"),
516
564
  autoTask,
517
- selfPaced: triggerInput === "self-paced" || prompt.toLowerCase().includes("self-paced"),
518
565
  readOnly,
519
566
  maxFires,
520
567
  });
@@ -536,6 +583,8 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
536
583
  } catch { /* filter parse failure, ignore */ }
537
584
  }
538
585
 
586
+ const bootstrapped = await maybeBootstrapTaskLoop(entry);
587
+
539
588
  widget.update();
540
589
 
541
590
  const triggerDesc = trigger.type === "cron"
@@ -549,6 +598,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
549
598
  `Trigger: ${triggerDesc}\n` +
550
599
  `Recurring: ${entry.recurring}\n` +
551
600
  (entry.autoTask ? `Auto-task: enabled\n` : "") +
601
+ (bootstrapped ? "Bootstrap: queued initial wake for existing pending tasks\n" : "") +
552
602
  ((tasksAvailable || nativeTasksRegistered) ? "" : "(task system not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available)\n") +
553
603
  `ID: ${entry.id} (use LoopDelete to cancel)`
554
604
  ));
@@ -1040,6 +1090,12 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
1040
1090
  }
1041
1091
  if (trimmed) {
1042
1092
  const entry = nativeTaskStore.create(trimmed.slice(0, 80), trimmed);
1093
+ pi.events.emit("tasks:created", {
1094
+ taskId: entry.id,
1095
+ subject: entry.subject,
1096
+ description: entry.description,
1097
+ status: entry.status,
1098
+ });
1043
1099
  widget.update();
1044
1100
  ctx.ui.notify(`Task #${entry.id} created`, "info");
1045
1101
  return;
@@ -1067,6 +1123,12 @@ Fields:
1067
1123
  }),
1068
1124
  execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
1069
1125
  const entry = taskStore.create(params.subject, params.description);
1126
+ pi.events.emit("tasks:created", {
1127
+ taskId: entry.id,
1128
+ subject: entry.subject,
1129
+ description: entry.description,
1130
+ status: entry.status,
1131
+ });
1070
1132
  widget.update();
1071
1133
  return Promise.resolve(textResult(`Task #${entry.id} created: ${entry.subject}`));
1072
1134
  },
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;