@trevonistrevon/pi-loop 0.4.0 → 0.4.1

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.
@@ -1,5 +1,27 @@
1
1
  # Differential Review Report
2
2
 
3
+ ## Update — 2026-06-02
4
+
5
+ ### Resolved audit finding
6
+ - **Resolved:** recurring `event` / `hybrid` loops now clean themselves up immediately when `maxFires` is reached on the final allowed fire.
7
+ - **Implementation:** `src/trigger-system.ts` now removes and deletes recurring event/hybrid loops as soon as `fireCount >= maxFires` after `onFire(...)` completes.
8
+ - **Why this mattered:** the previous behavior left one stale active loop behind until the next matching event arrived, which was a real runtime cleanup bug.
9
+
10
+ ### Regression coverage added
11
+ - `test/trigger-system.test.ts`
12
+ - recurring `event` loop is deleted immediately at final `maxFires`
13
+ - recurring `hybrid` loop is deleted immediately at final `maxFires`
14
+ - hybrid cleanup also clears scheduled cron state
15
+ - `test/index.test.ts`
16
+ - extension-level `LoopCreate`/`LoopList` path confirms the loop is gone immediately after the final allowed event fire
17
+
18
+ ### Validation status
19
+ - `npm run lint` ✅
20
+ - `npm run typecheck` ✅
21
+ - `npm run test` ✅
22
+ - `npm run build` ✅
23
+ - Current suite: **112 passing tests**
24
+
3
25
  ## Scope
4
26
  Reviewed recent uncommitted changes in:
5
27
  - `src/index.ts`
package/dist/index.js CHANGED
@@ -180,6 +180,87 @@ export default function (pi) {
180
180
  widget.update();
181
181
  }
182
182
  }
183
+ let agentRunning = false;
184
+ const pendingNotifications = new Map();
185
+ let flushPromise;
186
+ function buildLoopFireMessage(data) {
187
+ const triggerInfo = typeof data.trigger === "string"
188
+ ? data.trigger
189
+ : data.trigger?.type === "cron"
190
+ ? `schedule: ${data.trigger.schedule}`
191
+ : data.trigger?.type === "event"
192
+ ? `event: ${data.trigger.source}`
193
+ : "hybrid";
194
+ const loopId = data.loopId || "?";
195
+ const prompt = data.prompt || "loop fired";
196
+ const constraint = data.readOnly
197
+ ? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, etc.). No file writes, shell execution, or destructive changes."
198
+ : "";
199
+ return [
200
+ `[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
201
+ prompt,
202
+ ].join("\n");
203
+ }
204
+ function buildPendingNotification(data) {
205
+ const key = data.recurring ? `loop:${data.loopId}` : `loop:${data.loopId}:${data.timestamp}`;
206
+ return {
207
+ ...data,
208
+ key,
209
+ message: buildLoopFireMessage(data),
210
+ };
211
+ }
212
+ async function deliverNotification(notification) {
213
+ if (notification.autoTask) {
214
+ const pending = await hasPendingTasks();
215
+ if (pending === 0) {
216
+ debug(`loop:fire #${notification.loopId} — no pending tasks at delivery time, dropping wake`);
217
+ await cleanDoneTasks();
218
+ return false;
219
+ }
220
+ }
221
+ agentRunning = true;
222
+ pi.sendMessage({
223
+ customType: "pi-loop",
224
+ content: notification.message,
225
+ display: false,
226
+ details: {
227
+ loopId: notification.loopId,
228
+ trigger: notification.trigger,
229
+ recurring: notification.recurring,
230
+ readOnly: notification.readOnly,
231
+ autoTask: notification.autoTask,
232
+ timestamp: notification.timestamp,
233
+ },
234
+ }, {
235
+ deliverAs: "steer",
236
+ triggerTurn: true,
237
+ });
238
+ return true;
239
+ }
240
+ async function flushPendingNotifications() {
241
+ if (flushPromise)
242
+ return flushPromise;
243
+ flushPromise = (async () => {
244
+ if (agentRunning || _latestCtx?.hasPendingMessages())
245
+ return;
246
+ const entries = [...pendingNotifications.entries()]
247
+ .sort(([, left], [, right]) => left.timestamp - right.timestamp);
248
+ for (const [key, notification] of entries) {
249
+ pendingNotifications.delete(key);
250
+ const delivered = await deliverNotification(notification);
251
+ if (delivered)
252
+ return;
253
+ }
254
+ })().finally(() => {
255
+ flushPromise = undefined;
256
+ });
257
+ return flushPromise;
258
+ }
259
+ async function queueOrDeliverNotification(data) {
260
+ const notification = buildPendingNotification(data);
261
+ pendingNotifications.set(notification.key, notification);
262
+ await flushPendingNotifications();
263
+ }
183
264
  // ── Loop fire handler ──
184
265
  function onLoopFire(entry) {
185
266
  debug(`loop:fire #${entry.id}`, { prompt: entry.prompt.slice(0, 50) });
@@ -248,10 +329,27 @@ export default function (pi) {
248
329
  showPersistedLoops();
249
330
  widget.update();
250
331
  });
332
+ pi.on("agent_start", async (_event, ctx) => {
333
+ agentRunning = true;
334
+ _latestCtx = ctx;
335
+ widget.setUICtx(ctx.ui);
336
+ });
337
+ pi.on("agent_end", async (_event, ctx) => {
338
+ agentRunning = false;
339
+ _latestCtx = ctx;
340
+ widget.setUICtx(ctx.ui);
341
+ await flushPendingNotifications();
342
+ });
343
+ pi.on("session_shutdown", async () => {
344
+ agentRunning = false;
345
+ pendingNotifications.clear();
346
+ });
251
347
  pi.on("session_switch", async (event, ctx) => {
252
348
  _latestCtx = ctx;
253
349
  widget.setUICtx(ctx.ui);
254
350
  triggerSystem.stop();
351
+ agentRunning = false;
352
+ pendingNotifications.clear();
255
353
  const isResume = event?.reason === "resume";
256
354
  storeUpgraded = false;
257
355
  persistedShown = false;
@@ -262,36 +360,18 @@ export default function (pi) {
262
360
  showPersistedLoops(isResume);
263
361
  widget.update();
264
362
  });
265
- // ── Loop fire handler — sends a user message to re-wake the agent ──
363
+ // ── Loop fire handler — queues an in-memory notification, then injects a custom message when delivery is safe ──
266
364
  pi.events.on("loop:fire", async (event) => {
267
365
  const data = event;
268
- if (data.recurring && _latestCtx?.hasPendingMessages()) {
269
- debug(`loop:fire #${data.loopId} — agent has pending messages, skipping recurring fire`);
270
- return;
271
- }
272
366
  if (data.autoTask) {
273
367
  const pending = await hasPendingTasks();
274
368
  if (pending === 0) {
275
369
  debug(`loop:fire #${data.loopId} — no pending tasks, skipping, requesting cleanup`);
276
- cleanDoneTasks();
370
+ await cleanDoneTasks();
277
371
  return;
278
372
  }
279
373
  }
280
- const triggerInfo = typeof data.trigger === "string"
281
- ? data.trigger
282
- : data.trigger?.type === "cron"
283
- ? `schedule: ${data.trigger.schedule}`
284
- : data.trigger?.type === "event"
285
- ? `event: ${data.trigger.source}`
286
- : `hybrid`;
287
- const loopId = data.loopId || "?";
288
- const prompt = data.prompt || "loop fired";
289
- const constraint = data.readOnly ? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, LoopCreate, etc.). No file writes, shell execution, or destructive changes." : "";
290
- const message = [
291
- `[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
292
- prompt,
293
- ].join("\n");
294
- pi.sendUserMessage(message, { deliverAs: "followUp" });
374
+ await queueOrDeliverNotification(data);
295
375
  });
296
376
  // ──────────────────────────────────────────────────
297
377
  // Tool 1: LoopCreate
@@ -327,7 +407,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
327
407
  - **trigger**: interval like "30s", "5m", "2h", event source, or hybrid spec
328
408
  - **prompt**: what to do when the loop fires (e.g., "check if the build passed")
329
409
  - **recurring**: repeat or fire once (default: true)
330
- - **autoTask**: if pi-tasks is loaded, auto-create a task on each fire
410
+ - **autoTask**: when pi-tasks is loaded or native task fallback is active, auto-create a task on each fire
331
411
  - **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)
332
412
  - **maxFires**: auto-stop after N fires — prevents infinite token burn on polling loops`,
333
413
  promptGuidelines: [
@@ -346,7 +426,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
346
426
  "Set readOnly: true for loops that only observe and report (checks, status polls). This prevents unintended changes.",
347
427
  "## Task-driven workflows",
348
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.'",
349
- "When no tasks are pending, the loop skips the follow-up — no tokens burned on empty polls.",
429
+ "When no tasks are pending, the loop skips the wake entirely — no tokens burned on empty polls.",
350
430
  "After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
351
431
  ],
352
432
  parameters: Type.Object({
@@ -417,7 +497,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
417
497
  `Trigger: ${triggerDesc}\n` +
418
498
  `Recurring: ${entry.recurring}\n` +
419
499
  (entry.autoTask ? `Auto-task: enabled\n` : "") +
420
- (tasksAvailable ? "" : "(pi-tasks not detected — autoTask will have no effect)\n") +
500
+ ((tasksAvailable || nativeTasksRegistered) ? "" : "(task system not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available)\n") +
421
501
  `ID: ${entry.id} (use LoopDelete to cancel)`));
422
502
  },
423
503
  });
@@ -548,7 +628,7 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
548
628
 
549
629
  Fire off a build check, CI monitor, experiment, script, or any slow command — then keep working. Output streams back as "monitor:output" events. When the process exits, "monitor:done" fires (or "monitor:error" on failure).
550
630
 
551
- If you pass onDone with a prompt, the monitor auto-creates a one-shot completion loop — you get a system reminder with the exit code and output line count. No need to poll or create a separate loop.
631
+ If you pass onDone with a prompt, the monitor auto-creates a one-shot completion loop — you get a completion wake with the exit code and output line count. No need to poll or create a separate loop.
552
632
 
553
633
  DO NOT use raw Bash while/sleep/for loops to watch something. DO NOT run slow commands inline that could be offloaded. Use MonitorCreate to run work in parallel while you continue.
554
634
 
@@ -562,7 +642,7 @@ Default to MonitorCreate for any long-running or background work:\n- Watch a CI/
562
642
 
563
643
  ## onDone — auto-notify on completion
564
644
 
565
- Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fires when the process exits. The system reminder includes the exit code and output line count.\n\nExample: MonitorCreate command="python train.py" onDone="Check training results and report best loss"\nExample: MonitorCreate command="hut builds show 1769753" onDone="Analyze the build result and report status"`,
645
+ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fires when the process exits. The completion wake includes the exit code and output line count.\n\nExample: MonitorCreate command="python train.py" onDone="Check training results and report best loss"\nExample: MonitorCreate command="hut builds show 1769753" onDone="Analyze the build result and report status"`,
566
646
  promptGuidelines: [
567
647
  "Default to MonitorCreate for any long-running or background command — releases the agent to keep working on other tasks in parallel.",
568
648
  "When the user asks to monitor CI builds, watch a build, check a remote job, or run an experiment, use MonitorCreate instead of inline bash/curl/wait.",
@@ -572,7 +652,7 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
572
652
  command: Type.String({ description: "Shell command to run in background" }),
573
653
  description: Type.Optional(Type.String({ description: "Human-readable description" })),
574
654
  timeout: Type.Optional(Type.Number({ description: "Auto-stop after N ms (default: 300000, 0 = no timeout)", default: 300000 })),
575
- onDone: Type.Optional(Type.String({ description: "Prompt to run when the monitor completes. Auto-creates a one-shot completion loop — no need for a separate LoopCreate." })),
655
+ onDone: Type.Optional(Type.String({ description: "Prompt to run when the monitor completes. Auto-creates a one-shot completion wake — no need for a separate LoopCreate." })),
576
656
  }),
577
657
  execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
578
658
  if (monitorManager.list().filter(m => m.status === "running").length >= 25) {
@@ -103,6 +103,11 @@ export class TriggerSystem {
103
103
  this.remove(entry.id);
104
104
  return;
105
105
  }
106
+ if (fresh.recurring && fresh.maxFires && (fresh.fireCount ?? 0) >= fresh.maxFires) {
107
+ this.remove(fresh.id);
108
+ this.store.delete(fresh.id);
109
+ return;
110
+ }
106
111
  if (!fresh.recurring) {
107
112
  this.remove(fresh.id);
108
113
  this.store.delete(fresh.id);
@@ -9,8 +9,6 @@ export declare class LoopWidget {
9
9
  private store;
10
10
  private monitorManager;
11
11
  private uiCtx;
12
- private tui;
13
- private widgetRegistered;
14
12
  private interval;
15
13
  private taskSummaryProvider;
16
14
  constructor(store: LoopStore, monitorManager: MonitorManager);
@@ -18,7 +16,7 @@ export declare class LoopWidget {
18
16
  setStore(store: LoopStore): void;
19
17
  setTaskSummaryProvider(provider: (() => TaskSummary) | undefined): void;
20
18
  update(): void;
21
- private renderWidget;
19
+ private computeStatus;
22
20
  dispose(): void;
23
21
  }
24
22
  export {};
package/dist/ui/widget.js CHANGED
@@ -1,10 +1,7 @@
1
- import { truncateToWidth } from "@earendil-works/pi-tui";
2
1
  export class LoopWidget {
3
2
  store;
4
3
  monitorManager;
5
4
  uiCtx;
6
- tui;
7
- widgetRegistered = false;
8
5
  interval;
9
6
  taskSummaryProvider;
10
7
  constructor(store, monitorManager) {
@@ -23,34 +20,22 @@ export class LoopWidget {
23
20
  update() {
24
21
  if (!this.uiCtx)
25
22
  return;
26
- const taskSummary = this.taskSummaryProvider?.() ?? { count: 0 };
27
- const hasContent = this.store.list().some(l => l.status === "active") || this.monitorManager.list().length > 0 || taskSummary.count > 0;
28
- if (hasContent && !this.interval) {
23
+ const status = this.computeStatus();
24
+ if (status && !this.interval) {
29
25
  this.interval = setInterval(() => this.update(), 5000);
30
26
  }
31
- if (!hasContent && this.interval) {
27
+ if (!status && this.interval) {
32
28
  clearInterval(this.interval);
33
29
  this.interval = undefined;
34
30
  }
35
- if (!this.widgetRegistered) {
36
- this.uiCtx.setWidget("loops", (tui, theme) => {
37
- this.tui = tui;
38
- return { render: () => this.renderWidget(tui, theme), invalidate: () => { } };
39
- }, { placement: "aboveEditor" });
40
- this.widgetRegistered = true;
41
- }
42
- else if (this.tui) {
43
- this.tui.requestRender();
44
- }
31
+ this.uiCtx.setStatus("loops", status);
45
32
  }
46
- renderWidget(tui, _theme) {
33
+ computeStatus() {
47
34
  const loops = this.store.list().filter(l => l.status === "active");
48
35
  const monitors = this.monitorManager.list();
49
36
  const taskSummary = this.taskSummaryProvider?.() ?? { count: 0 };
50
- const w = tui.terminal.columns;
51
- const trunc = (line) => truncateToWidth(line, w);
52
37
  if (loops.length === 0 && monitors.length === 0 && taskSummary.count === 0) {
53
- return [trunc("none")];
38
+ return undefined;
54
39
  }
55
40
  const parts = [];
56
41
  if (loops.length > 0)
@@ -62,17 +47,14 @@ export class LoopWidget {
62
47
  let line = parts.join(" · ");
63
48
  if (taskSummary.focusText)
64
49
  line += ` | ${taskSummary.focusText}`;
65
- return [trunc(line)];
50
+ return line;
66
51
  }
67
52
  dispose() {
68
53
  if (this.interval) {
69
54
  clearInterval(this.interval);
70
55
  this.interval = undefined;
71
56
  }
72
- if (this.uiCtx)
73
- this.uiCtx.setWidget("loops", undefined);
74
- this.widgetRegistered = false;
75
- this.tui = undefined;
57
+ this.uiCtx?.setStatus("loops", undefined);
76
58
  }
77
59
  }
78
60
  function formatCount(count, noun) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trevonistrevon/pi-loop",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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
@@ -50,6 +50,10 @@ interface SessionSwitchEvent {
50
50
  reason?: string;
51
51
  }
52
52
 
53
+ interface PendingNotification extends LoopFireEvent {
54
+ key: string;
55
+ message: string;
56
+ }
53
57
 
54
58
  export default function (pi: ExtensionAPI) {
55
59
  const piLoopEnv = process.env.PI_LOOP;
@@ -191,6 +195,97 @@ export default function (pi: ExtensionAPI) {
191
195
  }
192
196
  }
193
197
 
198
+ let agentRunning = false;
199
+ const pendingNotifications = new Map<string, PendingNotification>();
200
+ let flushPromise: Promise<void> | undefined;
201
+
202
+ function buildLoopFireMessage(data: LoopFireEvent): string {
203
+ const triggerInfo = typeof data.trigger === "string"
204
+ ? data.trigger
205
+ : data.trigger?.type === "cron"
206
+ ? `schedule: ${data.trigger.schedule}`
207
+ : data.trigger?.type === "event"
208
+ ? `event: ${data.trigger.source}`
209
+ : "hybrid";
210
+
211
+ const loopId = data.loopId || "?";
212
+ const prompt = data.prompt || "loop fired";
213
+ const constraint = data.readOnly
214
+ ? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, etc.). No file writes, shell execution, or destructive changes."
215
+ : "";
216
+
217
+ return [
218
+ `[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
219
+ prompt,
220
+ ].join("\n");
221
+ }
222
+
223
+ function buildPendingNotification(data: LoopFireEvent): PendingNotification {
224
+ const key = data.recurring ? `loop:${data.loopId}` : `loop:${data.loopId}:${data.timestamp}`;
225
+ return {
226
+ ...data,
227
+ key,
228
+ message: buildLoopFireMessage(data),
229
+ };
230
+ }
231
+
232
+ async function deliverNotification(notification: PendingNotification): Promise<boolean> {
233
+ if (notification.autoTask) {
234
+ const pending = await hasPendingTasks();
235
+ if (pending === 0) {
236
+ debug(`loop:fire #${notification.loopId} — no pending tasks at delivery time, dropping wake`);
237
+ await cleanDoneTasks();
238
+ return false;
239
+ }
240
+ }
241
+
242
+ agentRunning = true;
243
+ pi.sendMessage({
244
+ customType: "pi-loop",
245
+ content: notification.message,
246
+ display: false,
247
+ details: {
248
+ loopId: notification.loopId,
249
+ trigger: notification.trigger,
250
+ recurring: notification.recurring,
251
+ readOnly: notification.readOnly,
252
+ autoTask: notification.autoTask,
253
+ timestamp: notification.timestamp,
254
+ },
255
+ }, {
256
+ deliverAs: "steer",
257
+ triggerTurn: true,
258
+ });
259
+ return true;
260
+ }
261
+
262
+ async function flushPendingNotifications(): Promise<void> {
263
+ if (flushPromise) return flushPromise;
264
+
265
+ flushPromise = (async () => {
266
+ if (agentRunning || _latestCtx?.hasPendingMessages()) return;
267
+
268
+ const entries = [...pendingNotifications.entries()]
269
+ .sort(([, left], [, right]) => left.timestamp - right.timestamp);
270
+
271
+ for (const [key, notification] of entries) {
272
+ pendingNotifications.delete(key);
273
+ const delivered = await deliverNotification(notification);
274
+ if (delivered) return;
275
+ }
276
+ })().finally(() => {
277
+ flushPromise = undefined;
278
+ });
279
+
280
+ return flushPromise;
281
+ }
282
+
283
+ async function queueOrDeliverNotification(data: LoopFireEvent): Promise<void> {
284
+ const notification = buildPendingNotification(data);
285
+ pendingNotifications.set(notification.key, notification);
286
+ await flushPendingNotifications();
287
+ }
288
+
194
289
  // ── Loop fire handler ──
195
290
 
196
291
  function onLoopFire(entry: LoopEntry): void {
@@ -267,10 +362,30 @@ export default function (pi: ExtensionAPI) {
267
362
  widget.update();
268
363
  });
269
364
 
365
+ pi.on("agent_start", async (_event, ctx) => {
366
+ agentRunning = true;
367
+ _latestCtx = ctx;
368
+ widget.setUICtx(ctx.ui);
369
+ });
370
+
371
+ pi.on("agent_end", async (_event, ctx) => {
372
+ agentRunning = false;
373
+ _latestCtx = ctx;
374
+ widget.setUICtx(ctx.ui);
375
+ await flushPendingNotifications();
376
+ });
377
+
378
+ pi.on("session_shutdown", async () => {
379
+ agentRunning = false;
380
+ pendingNotifications.clear();
381
+ });
382
+
270
383
  pi.on("session_switch" as any, async (event: SessionSwitchEvent, ctx: ExtensionContext) => {
271
384
  _latestCtx = ctx;
272
385
  widget.setUICtx(ctx.ui);
273
386
  triggerSystem.stop();
387
+ agentRunning = false;
388
+ pendingNotifications.clear();
274
389
 
275
390
  const isResume = event?.reason === "resume";
276
391
  storeUpgraded = false;
@@ -285,42 +400,21 @@ export default function (pi: ExtensionAPI) {
285
400
  widget.update();
286
401
  });
287
402
 
288
- // ── Loop fire handler — sends a user message to re-wake the agent ──
403
+ // ── Loop fire handler — queues an in-memory notification, then injects a custom message when delivery is safe ──
289
404
 
290
405
  pi.events.on("loop:fire", async (event: unknown) => {
291
406
  const data = event as LoopFireEvent;
292
407
 
293
- if (data.recurring && _latestCtx?.hasPendingMessages()) {
294
- debug(`loop:fire #${data.loopId} — agent has pending messages, skipping recurring fire`);
295
- return;
296
- }
297
-
298
408
  if (data.autoTask) {
299
409
  const pending = await hasPendingTasks();
300
410
  if (pending === 0) {
301
411
  debug(`loop:fire #${data.loopId} — no pending tasks, skipping, requesting cleanup`);
302
- cleanDoneTasks();
412
+ await cleanDoneTasks();
303
413
  return;
304
414
  }
305
415
  }
306
416
 
307
- const triggerInfo = typeof data.trigger === "string"
308
- ? data.trigger
309
- : data.trigger?.type === "cron"
310
- ? `schedule: ${data.trigger.schedule}`
311
- : data.trigger?.type === "event"
312
- ? `event: ${data.trigger.source}`
313
- : `hybrid`;
314
-
315
- const loopId = data.loopId || "?";
316
- const prompt = data.prompt || "loop fired";
317
- const constraint = data.readOnly ? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, LoopCreate, etc.). No file writes, shell execution, or destructive changes." : "";
318
- const message = [
319
- `[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
320
- prompt,
321
- ].join("\n");
322
-
323
- pi.sendUserMessage(message, { deliverAs: "followUp" });
417
+ await queueOrDeliverNotification(data);
324
418
  });
325
419
 
326
420
  // ──────────────────────────────────────────────────
@@ -358,7 +452,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
358
452
  - **trigger**: interval like "30s", "5m", "2h", event source, or hybrid spec
359
453
  - **prompt**: what to do when the loop fires (e.g., "check if the build passed")
360
454
  - **recurring**: repeat or fire once (default: true)
361
- - **autoTask**: if pi-tasks is loaded, auto-create a task on each fire
455
+ - **autoTask**: when pi-tasks is loaded or native task fallback is active, auto-create a task on each fire
362
456
  - **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)
363
457
  - **maxFires**: auto-stop after N fires — prevents infinite token burn on polling loops`,
364
458
  promptGuidelines: [
@@ -377,7 +471,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
377
471
  "Set readOnly: true for loops that only observe and report (checks, status polls). This prevents unintended changes.",
378
472
  "## Task-driven workflows",
379
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.'",
380
- "When no tasks are pending, the loop skips the follow-up — no tokens burned on empty polls.",
474
+ "When no tasks are pending, the loop skips the wake entirely — no tokens burned on empty polls.",
381
475
  "After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
382
476
  ],
383
477
  parameters: Type.Object({
@@ -455,7 +549,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
455
549
  `Trigger: ${triggerDesc}\n` +
456
550
  `Recurring: ${entry.recurring}\n` +
457
551
  (entry.autoTask ? `Auto-task: enabled\n` : "") +
458
- (tasksAvailable ? "" : "(pi-tasks not detected — autoTask will have no effect)\n") +
552
+ ((tasksAvailable || nativeTasksRegistered) ? "" : "(task system not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available)\n") +
459
553
  `ID: ${entry.id} (use LoopDelete to cancel)`
460
554
  ));
461
555
  },
@@ -594,7 +688,7 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
594
688
 
595
689
  Fire off a build check, CI monitor, experiment, script, or any slow command — then keep working. Output streams back as "monitor:output" events. When the process exits, "monitor:done" fires (or "monitor:error" on failure).
596
690
 
597
- If you pass onDone with a prompt, the monitor auto-creates a one-shot completion loop — you get a system reminder with the exit code and output line count. No need to poll or create a separate loop.
691
+ If you pass onDone with a prompt, the monitor auto-creates a one-shot completion loop — you get a completion wake with the exit code and output line count. No need to poll or create a separate loop.
598
692
 
599
693
  DO NOT use raw Bash while/sleep/for loops to watch something. DO NOT run slow commands inline that could be offloaded. Use MonitorCreate to run work in parallel while you continue.
600
694
 
@@ -608,7 +702,7 @@ Default to MonitorCreate for any long-running or background work:\n- Watch a CI/
608
702
 
609
703
  ## onDone — auto-notify on completion
610
704
 
611
- Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fires when the process exits. The system reminder includes the exit code and output line count.\n\nExample: MonitorCreate command="python train.py" onDone="Check training results and report best loss"\nExample: MonitorCreate command="hut builds show 1769753" onDone="Analyze the build result and report status"`,
705
+ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fires when the process exits. The completion wake includes the exit code and output line count.\n\nExample: MonitorCreate command="python train.py" onDone="Check training results and report best loss"\nExample: MonitorCreate command="hut builds show 1769753" onDone="Analyze the build result and report status"`,
612
706
  promptGuidelines: [
613
707
  "Default to MonitorCreate for any long-running or background command — releases the agent to keep working on other tasks in parallel.",
614
708
  "When the user asks to monitor CI builds, watch a build, check a remote job, or run an experiment, use MonitorCreate instead of inline bash/curl/wait.",
@@ -618,7 +712,7 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
618
712
  command: Type.String({ description: "Shell command to run in background" }),
619
713
  description: Type.Optional(Type.String({ description: "Human-readable description" })),
620
714
  timeout: Type.Optional(Type.Number({ description: "Auto-stop after N ms (default: 300000, 0 = no timeout)", default: 300000 })),
621
- onDone: Type.Optional(Type.String({ description: "Prompt to run when the monitor completes. Auto-creates a one-shot completion loop — no need for a separate LoopCreate." })),
715
+ onDone: Type.Optional(Type.String({ description: "Prompt to run when the monitor completes. Auto-creates a one-shot completion wake — no need for a separate LoopCreate." })),
622
716
  }),
623
717
 
624
718
  execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
@@ -110,6 +110,12 @@ export class TriggerSystem {
110
110
  return;
111
111
  }
112
112
 
113
+ if (fresh.recurring && fresh.maxFires && (fresh.fireCount ?? 0) >= fresh.maxFires) {
114
+ this.remove(fresh.id);
115
+ this.store.delete(fresh.id);
116
+ return;
117
+ }
118
+
113
119
  if (!fresh.recurring) {
114
120
  this.remove(fresh.id);
115
121
  this.store.delete(fresh.id);
package/src/ui/widget.ts CHANGED
@@ -1,6 +1,4 @@
1
- import type { ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
2
- import type { Component, TUI } from "@earendil-works/pi-tui";
3
- import { truncateToWidth } from "@earendil-works/pi-tui";
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
4
2
  import type { MonitorManager } from "../monitor-manager.js";
5
3
  import type { LoopStore } from "../store.js";
6
4
 
@@ -11,8 +9,6 @@ interface TaskSummary {
11
9
 
12
10
  export class LoopWidget {
13
11
  private uiCtx: ExtensionUIContext | undefined;
14
- private tui: TUI | undefined;
15
- private widgetRegistered = false;
16
12
  private interval: ReturnType<typeof setInterval> | undefined;
17
13
  private taskSummaryProvider: (() => TaskSummary) | undefined;
18
14
 
@@ -36,36 +32,25 @@ export class LoopWidget {
36
32
  update() {
37
33
  if (!this.uiCtx) return;
38
34
 
39
- const taskSummary = this.taskSummaryProvider?.() ?? { count: 0 };
40
- const hasContent = this.store.list().some(l => l.status === "active") || this.monitorManager.list().length > 0 || taskSummary.count > 0;
41
- if (hasContent && !this.interval) {
35
+ const status = this.computeStatus();
36
+ if (status && !this.interval) {
42
37
  this.interval = setInterval(() => this.update(), 5000);
43
38
  }
44
- if (!hasContent && this.interval) {
39
+ if (!status && this.interval) {
45
40
  clearInterval(this.interval);
46
41
  this.interval = undefined;
47
42
  }
48
43
 
49
- if (!this.widgetRegistered) {
50
- this.uiCtx.setWidget("loops", (tui: TUI, theme: Theme) => {
51
- this.tui = tui;
52
- return { render: () => this.renderWidget(tui, theme), invalidate: () => {} } as Component & { dispose?(): void };
53
- }, { placement: "aboveEditor" });
54
- this.widgetRegistered = true;
55
- } else if (this.tui) {
56
- (this.tui as any).requestRender();
57
- }
44
+ this.uiCtx.setStatus("loops", status);
58
45
  }
59
46
 
60
- private renderWidget(tui: TUI, _theme: Theme): string[] {
47
+ private computeStatus(): string | undefined {
61
48
  const loops = this.store.list().filter(l => l.status === "active");
62
49
  const monitors = this.monitorManager.list();
63
50
  const taskSummary = this.taskSummaryProvider?.() ?? { count: 0 };
64
- const w = tui.terminal.columns;
65
- const trunc = (line: string) => truncateToWidth(line, w);
66
51
 
67
52
  if (loops.length === 0 && monitors.length === 0 && taskSummary.count === 0) {
68
- return [trunc("none")];
53
+ return undefined;
69
54
  }
70
55
 
71
56
  const parts: string[] = [];
@@ -75,14 +60,15 @@ export class LoopWidget {
75
60
 
76
61
  let line = parts.join(" · ");
77
62
  if (taskSummary.focusText) line += ` | ${taskSummary.focusText}`;
78
- return [trunc(line)];
63
+ return line;
79
64
  }
80
65
 
81
66
  dispose() {
82
- if (this.interval) { clearInterval(this.interval); this.interval = undefined; }
83
- if (this.uiCtx) this.uiCtx.setWidget("loops", undefined);
84
- this.widgetRegistered = false;
85
- this.tui = undefined;
67
+ if (this.interval) {
68
+ clearInterval(this.interval);
69
+ this.interval = undefined;
70
+ }
71
+ this.uiCtx?.setStatus("loops", undefined);
86
72
  }
87
73
  }
88
74