@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 +4 -0
- package/dist/index.js +68 -4
- 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 +66 -4
- 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
|
@@ -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
|
-
"
|
|
429
|
-
"
|
|
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
|
},
|
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
|
@@ -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
|
-
"
|
|
474
|
-
"
|
|
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
|
-
|
|
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,
|