@trevonistrevon/pi-loop 0.2.8 → 0.3.0

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/dist/index.js CHANGED
@@ -106,9 +106,36 @@ export default function (pi) {
106
106
  return undefined;
107
107
  }
108
108
  }
109
+ async function hasPendingTasks() {
110
+ if (!tasksAvailable)
111
+ return -1;
112
+ try {
113
+ const requestId = randomUUID();
114
+ const count = await new Promise((resolve) => {
115
+ const timer = setTimeout(() => { unsub(); resolve(-1); }, 3000);
116
+ const unsub = pi.events.on(`tasks:rpc:pending:reply:${requestId}`, (raw) => {
117
+ unsub();
118
+ clearTimeout(timer);
119
+ const reply = raw;
120
+ resolve(reply.success && reply.data ? reply.data.pending : -1);
121
+ });
122
+ pi.events.emit("tasks:rpc:pending", { requestId });
123
+ });
124
+ return count;
125
+ }
126
+ catch {
127
+ return -1;
128
+ }
129
+ }
109
130
  // ── Loop fire handler ──
110
131
  function onLoopFire(entry) {
111
132
  debug(`loop:fire #${entry.id}`, { prompt: entry.prompt.slice(0, 50) });
133
+ if (entry.maxFires && (entry.fireCount ?? 0) >= entry.maxFires) {
134
+ debug(`loop #${entry.id} — reached maxFires ${entry.maxFires}, expiring`);
135
+ store.update(entry.id, { status: "expired" });
136
+ return;
137
+ }
138
+ store.update(entry.id, { fireCount: (entry.fireCount ?? 0) + 1 });
112
139
  if (entry.autoTask) {
113
140
  autoCreateTask(entry).then((taskId) => {
114
141
  if (taskId)
@@ -122,6 +149,7 @@ export default function (pi) {
122
149
  timestamp: Date.now(),
123
150
  readOnly: entry.readOnly,
124
151
  recurring: entry.recurring,
152
+ autoTask: entry.autoTask,
125
153
  });
126
154
  }
127
155
  // ── Session lifecycle ──
@@ -180,12 +208,19 @@ export default function (pi) {
180
208
  showPersistedLoops(isResume);
181
209
  });
182
210
  // ── Loop fire handler — sends a user message to re-wake the agent ──
183
- pi.events.on("loop:fire", (event) => {
211
+ pi.events.on("loop:fire", async (event) => {
184
212
  const data = event;
185
213
  if (data.recurring && _latestCtx?.hasPendingMessages()) {
186
214
  debug(`loop:fire #${data.loopId} — agent has pending messages, skipping recurring fire`);
187
215
  return;
188
216
  }
217
+ if (data.autoTask) {
218
+ const pending = await hasPendingTasks();
219
+ if (pending === 0) {
220
+ debug(`loop:fire #${data.loopId} — no pending tasks, skipping`);
221
+ return;
222
+ }
223
+ }
189
224
  const triggerInfo = typeof data.trigger === "string"
190
225
  ? data.trigger
191
226
  : data.trigger?.type === "cron"
@@ -237,12 +272,26 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
237
272
  - **prompt**: what to do when the loop fires (e.g., "check if the build passed")
238
273
  - **recurring**: repeat or fire once (default: true)
239
274
  - **autoTask**: if pi-tasks is loaded, auto-create a task on each fire
240
- - **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)`,
275
+ - **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)
276
+ - **maxFires**: auto-stop after N fires — prevents infinite token burn on polling loops`,
241
277
  promptGuidelines: [
242
- "When the user asks for a loop, repeating task, periodic check, or scheduled reminder, use LoopCreatenot raw Bash for/sleep/while.",
243
- "Use LoopCreate for any 'every X seconds/minutes/hours' requests.",
278
+ "Use LoopCreate when the user asks for a repeating task, periodic check, scheduled reminder, or 'every X' never use raw Bash for/sleep/while.",
279
+ "## Choosing trigger type",
280
+ "Prefer event triggers over cron when possible — they fire exactly when needed instead of polling.",
281
+ "Use event triggers for: tool completion ('tool_execution_end'), task creation ('tasks:created'), monitor completion ('monitor:done').",
282
+ "Use cron triggers only when: the user explicitly asks for a time interval, or there's no relevant pi event to subscribe to.",
283
+ "Hybrid triggers (cron + event) give you both: event-driven responsiveness with a cron safety-net fallback.",
284
+ "## Choosing an interval",
285
+ "Default to 5m unless the user specifies differently. Use shorter intervals only when time-sensitive.",
286
+ "## maxFires — prevent infinite token burn",
287
+ "Always set maxFires on polling loops so they don't run forever. For task-continuation loops, use maxFires: 20-50.",
288
+ "When a loop fires and finds nothing to do, call LoopDelete on its own ID to stop it — don't keep polling.",
289
+ "## readOnly mode",
290
+ "Set readOnly: true for loops that only observe and report (checks, status polls). This prevents unintended changes.",
291
+ "## Task-driven workflows",
292
+ "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.'",
293
+ "When no tasks are pending, the loop skips the follow-up — no tokens burned on empty polls.",
244
294
  "After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
245
- "After creating tasks with TaskCreate, create a loop to keep working through them: LoopCreate trigger='30s' prompt='Run TaskList, pick the next available pending task, work on it. Continue until all tasks are completed.' This ensures the agent keeps making progress across turns instead of stopping after one task.",
246
295
  ],
247
296
  parameters: Type.Object({
248
297
  trigger: Type.String({ description: "Cron expression (e.g., '5m', '1h', '0 9 * * 1-5'), event source (e.g., 'tool_execution_start'), or JSON hybrid spec" }),
@@ -252,9 +301,10 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
252
301
  triggerType: Type.Optional(Type.String({ description: "cron, event, or hybrid (inferred from trigger string if omitted)", enum: ["cron", "event", "hybrid"] })),
253
302
  debounceMs: Type.Optional(Type.Number({ description: "Debounce for hybrid triggers (default: 30000)", default: 30000 })),
254
303
  readOnly: Type.Optional(Type.Boolean({ description: "Restrict the agent to read-only tools when this loop fires (default: false)", default: false })),
304
+ maxFires: Type.Optional(Type.Number({ description: "Auto-stop after N fires. Prevents infinite token burn on polling loops." })),
255
305
  }),
256
306
  execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
257
- const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs, readOnly } = params;
307
+ const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs, readOnly, maxFires } = params;
258
308
  let trigger;
259
309
  const inferred = triggerType ?? inferTriggerType(triggerInput);
260
310
  if (inferred === "cron") {
@@ -283,8 +333,24 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
283
333
  autoTask,
284
334
  selfPaced: triggerInput === "self-paced" || prompt.toLowerCase().includes("self-paced"),
285
335
  readOnly,
336
+ maxFires,
286
337
  });
287
338
  triggerSystem.add(entry);
339
+ if (trigger.type === "event" && trigger.source === "monitor:done" && trigger.filter) {
340
+ try {
341
+ const filterObj = JSON.parse(trigger.filter);
342
+ const monitorId = filterObj.monitorId;
343
+ if (monitorId) {
344
+ const monitor = monitorManager.get(monitorId);
345
+ if (monitor && monitor.status !== "running") {
346
+ debug(`loop #${entry.id} — monitor #${monitorId} already ${monitor.status}, expiring`);
347
+ triggerSystem.remove(entry.id);
348
+ store.update(entry.id, { status: "expired" });
349
+ }
350
+ }
351
+ }
352
+ catch { /* filter parse failure, ignore */ }
353
+ }
288
354
  widget.update();
289
355
  const triggerDesc = trigger.type === "cron"
290
356
  ? `schedule: ${trigger.schedule}`
@@ -299,6 +365,15 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
299
365
  `ID: ${entry.id} (use LoopDelete to cancel)`));
300
366
  },
301
367
  });
368
+ function handleMonitorDoneLoop(doneLoop, monitorId) {
369
+ triggerSystem.add(doneLoop);
370
+ const monitor = monitorManager.get(monitorId);
371
+ if (monitor && monitor.status !== "running") {
372
+ debug(`onDone loop #${doneLoop.id} — monitor #${monitorId} already ${monitor.status}, expiring`);
373
+ triggerSystem.remove(doneLoop.id);
374
+ store.update(doneLoop.id, { status: "expired" });
375
+ }
376
+ }
302
377
  function validateTrigger(trigger) {
303
378
  if (trigger.type === "cron") {
304
379
  const parts = trigger.schedule.trim().split(/\s+/);
@@ -453,7 +528,7 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
453
528
  if (params.onDone) {
454
529
  const doneTrigger = { type: "event", source: "monitor:done", filter: JSON.stringify({ monitorId: entry.id }) };
455
530
  const doneLoop = store.create(doneTrigger, params.onDone, { recurring: false });
456
- triggerSystem.add(doneLoop);
531
+ handleMonitorDoneLoop(doneLoop, entry.id);
457
532
  onDoneMsg = `\nonDone loop #${doneLoop.id}: fires when monitor completes — no polling needed`;
458
533
  }
459
534
  return Promise.resolve(textResult(`Monitor #${entry.id} started: ${entry.command.slice(0, 60)}\n` +
@@ -467,7 +542,7 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
467
542
  pi.registerTool({
468
543
  name: "MonitorList",
469
544
  label: "MonitorList",
470
- description: `List all monitors with their status, command, and output line count.`,
545
+ description: `List all monitors with their status, command, exit code, output line count, and last 5 lines of buffered output.`,
471
546
  parameters: Type.Object({}),
472
547
  execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
473
548
  const monitors = monitorManager.list();
@@ -478,7 +553,16 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
478
553
  const icon = m.status === "running" ? "◉" : m.status === "completed" ? "✓" : "✗";
479
554
  const age = Date.now() - m.startedAt;
480
555
  const ageStr = formatRemaining(age);
481
- lines.push(`${icon} #${m.id} [${m.status}] ${m.command.slice(0, 50)} — ${m.outputLines} lines (${ageStr})`);
556
+ let line = `${icon} #${m.id} [${m.status}] ${m.command.slice(0, 60)} — ${m.outputLines} lines (${ageStr})`;
557
+ if (m.exitCode !== undefined)
558
+ line += ` exit=${m.exitCode}`;
559
+ lines.push(line);
560
+ if (m.status !== "running" && m.outputBuffer.length > 0) {
561
+ const tail = m.outputBuffer.slice(-5);
562
+ for (const out of tail) {
563
+ lines.push(` | ${out.slice(0, 100)}`);
564
+ }
565
+ }
482
566
  }
483
567
  return Promise.resolve(textResult(lines.join("\n")));
484
568
  },
@@ -16,6 +16,7 @@ export class MonitorManager {
16
16
  status: "running",
17
17
  startedAt: Date.now(),
18
18
  outputLines: 0,
19
+ outputBuffer: [],
19
20
  };
20
21
  const abortController = new AbortController();
21
22
  const child = spawn("sh", ["-c", command], {
@@ -36,6 +37,8 @@ export class MonitorManager {
36
37
  if (line.length === 0)
37
38
  continue;
38
39
  entry.outputLines++;
40
+ if (entry.outputBuffer.length < 200)
41
+ entry.outputBuffer.push(line);
39
42
  this.pi.events.emit("monitor:output", {
40
43
  monitorId: id,
41
44
  line,
@@ -49,6 +52,8 @@ export class MonitorManager {
49
52
  if (line.length === 0)
50
53
  continue;
51
54
  entry.outputLines++;
55
+ if (entry.outputBuffer.length < 200)
56
+ entry.outputBuffer.push(line);
52
57
  this.pi.events.emit("monitor:output", {
53
58
  monitorId: id,
54
59
  line,
package/dist/scheduler.js CHANGED
@@ -49,7 +49,9 @@ export class CronScheduler {
49
49
  armTimer(entry) {
50
50
  const _scheduleExpr = entry.trigger.type === "hybrid" ? entry.trigger.cron : entry.trigger.schedule;
51
51
  const nextFire = computeNextFire(entry);
52
- const jitter = computeJitter(entry.id, entry.recurring, 30);
52
+ const minuteField = _scheduleExpr.trim().split(/\s+/)[0];
53
+ const minuteStep = minuteField.startsWith("*/") ? parseInt(minuteField.slice(2), 10) || 30 : 30;
54
+ const jitter = computeJitter(entry.id, entry.recurring, minuteStep);
53
55
  const fireTime = nextFire.getTime() + jitter;
54
56
  const now = Date.now();
55
57
  if (fireTime > entry.expiresAt) {
@@ -77,6 +79,13 @@ export class CronScheduler {
77
79
  }
78
80
  this.onFire(current);
79
81
  if (current.recurring) {
82
+ const fresh = this.store.get(entry.id);
83
+ if (fresh?.maxFires && (fresh.fireCount ?? 0) >= fresh.maxFires) {
84
+ this.store.update(entry.id, { status: "expired" });
85
+ this.timers.delete(entry.id);
86
+ this.fireTimes.delete(entry.id);
87
+ return;
88
+ }
80
89
  this.armTimer(current);
81
90
  }
82
91
  else {
package/dist/store.d.ts CHANGED
@@ -13,6 +13,7 @@ export declare class LoopStore {
13
13
  autoTask?: boolean;
14
14
  selfPaced?: boolean;
15
15
  readOnly?: boolean;
16
+ maxFires?: number;
16
17
  }): LoopEntry;
17
18
  get(id: string): LoopEntry | undefined;
18
19
  list(): LoopEntry[];
@@ -20,6 +21,7 @@ export declare class LoopStore {
20
21
  status?: LoopStatus;
21
22
  trigger?: Trigger;
22
23
  prompt?: string;
24
+ fireCount?: number;
23
25
  }): {
24
26
  entry: LoopEntry | undefined;
25
27
  changedFields: string[];
package/dist/store.js CHANGED
@@ -119,6 +119,8 @@ export class LoopStore {
119
119
  autoTask: opts.autoTask,
120
120
  selfPaced: opts.selfPaced,
121
121
  readOnly: opts.readOnly,
122
+ maxFires: opts.maxFires,
123
+ fireCount: 0,
122
124
  createdAt: now,
123
125
  updatedAt: now,
124
126
  expiresAt: now + MAX_EXPIRY_DAYS * 24 * 60 * 60 * 1000,
@@ -155,6 +157,10 @@ export class LoopStore {
155
157
  entry.prompt = fields.prompt;
156
158
  changedFields.push("prompt");
157
159
  }
160
+ if (fields.fireCount !== undefined) {
161
+ entry.fireCount = fields.fireCount;
162
+ changedFields.push("fireCount");
163
+ }
158
164
  entry.updatedAt = Date.now();
159
165
  return { entry, changedFields };
160
166
  });
@@ -183,16 +189,19 @@ export class LoopStore {
183
189
  expireEventLoops(sessionStartedAt) {
184
190
  return this.withLock(() => {
185
191
  let count = 0;
186
- for (const [_id, entry] of this.loops) {
192
+ const toDelete = [];
193
+ for (const [id, entry] of this.loops) {
187
194
  if (entry.status !== "active")
188
195
  continue;
189
196
  if (entry.trigger.type === "event" || entry.trigger.type === "hybrid") {
190
197
  if (entry.createdAt < sessionStartedAt) {
191
- entry.status = "expired";
198
+ toDelete.push(id);
192
199
  count++;
193
200
  }
194
201
  }
195
202
  }
203
+ for (const id of toDelete)
204
+ this.loops.delete(id);
196
205
  return count;
197
206
  });
198
207
  }
@@ -89,12 +89,21 @@ export class TriggerSystem {
89
89
  this.hybridTimers.set(entry.id, timer);
90
90
  }
91
91
  fireLoop(entry) {
92
+ if (entry.maxFires && (entry.fireCount ?? 0) >= entry.maxFires) {
93
+ this.store.update(entry.id, { status: "expired" });
94
+ this.remove(entry.id);
95
+ return;
96
+ }
97
+ this.store.update(entry.id, { fireCount: (entry.fireCount ?? 0) + 1 });
92
98
  this.lastFireTime.set(entry.id, Date.now());
93
99
  this.pi.events.emit("loop:fire", {
94
100
  loopId: entry.id,
95
101
  prompt: entry.prompt,
96
102
  trigger: entry.trigger,
97
103
  timestamp: Date.now(),
104
+ readOnly: entry.readOnly,
105
+ recurring: entry.recurring,
106
+ autoTask: entry.autoTask,
98
107
  });
99
108
  if (!entry.recurring) {
100
109
  this.remove(entry.id);
package/dist/types.d.ts CHANGED
@@ -30,6 +30,8 @@ export interface LoopEntry {
30
30
  autoTask?: boolean;
31
31
  selfPaced?: boolean;
32
32
  readOnly?: boolean;
33
+ maxFires?: number;
34
+ fireCount?: number;
33
35
  }
34
36
  export interface LoopStoreData {
35
37
  nextId: number;
@@ -45,6 +47,7 @@ export interface MonitorEntry {
45
47
  completedAt?: number;
46
48
  exitCode?: number;
47
49
  outputLines: number;
50
+ outputBuffer: string[];
48
51
  }
49
52
  export interface MonitorProcess {
50
53
  entry: MonitorEntry;
package/dist/ui/widget.js CHANGED
@@ -26,8 +26,9 @@ export class LoopWidget {
26
26
  if (!this.uiCtx)
27
27
  return;
28
28
  const loops = this.store.list().filter(l => l.status === "active");
29
- const monitors = this.monitorManager.list().filter(m => m.status === "running");
30
- if (loops.length === 0 && monitors.length === 0) {
29
+ const monitors = this.monitorManager.list();
30
+ const hasContent = loops.length > 0 || monitors.length > 0;
31
+ if (!hasContent) {
31
32
  if (this.widgetRegistered) {
32
33
  this.uiCtx.setWidget("loops", undefined);
33
34
  this.widgetRegistered = false;
@@ -54,14 +55,21 @@ export class LoopWidget {
54
55
  }
55
56
  renderWidget(tui, _theme) {
56
57
  const loops = this.store.list().filter(l => l.status === "active");
57
- const monitors = this.monitorManager.list().filter(m => m.status === "running");
58
+ const runningMonitors = this.monitorManager.list().filter(m => m.status === "running");
59
+ const completedMonitors = this.monitorManager.list().filter(m => m.status === "completed");
60
+ const allMonitors = [...runningMonitors, ...completedMonitors];
58
61
  const w = tui.terminal.columns;
59
62
  const trunc = (line) => truncateToWidth(line, w);
60
63
  const lines = [];
61
- const total = loops.length + monitors.length;
64
+ const total = loops.length + allMonitors.length;
62
65
  if (total === 0)
63
66
  return [];
64
- lines.push(trunc(`⟳ ${loops.length} loops · ${monitors.length} monitors`));
67
+ const headerParts = [`⟳ ${loops.length} loops`];
68
+ if (runningMonitors.length > 0)
69
+ headerParts.push(`${runningMonitors.length} running`);
70
+ if (completedMonitors.length > 0)
71
+ headerParts.push(`${completedMonitors.length} done`);
72
+ lines.push(trunc(headerParts.join(" · ")));
65
73
  for (const loop of loops.slice(0, MAX_VISIBLE)) {
66
74
  const icon = "◷";
67
75
  let schedule = "";
@@ -82,11 +90,14 @@ export class LoopWidget {
82
90
  }
83
91
  lines.push(trunc(` ${icon} #${loop.id} ${loop.prompt.slice(0, 50)} → ${schedule}${timing}`));
84
92
  }
85
- for (const m of monitors.slice(0, Math.max(0, MAX_VISIBLE - loops.length))) {
86
- const icon = "◉";
93
+ for (const m of allMonitors.slice(0, Math.max(0, MAX_VISIBLE - loops.length))) {
94
+ const icon = m.status === "running" ? "◉" : "✓";
87
95
  const age = Date.now() - m.startedAt;
88
96
  const label = m.description || m.command.replace(/\n/g, " ").replace(/\s+/g, " ").trim().slice(0, 50);
89
- lines.push(trunc(` ${icon} #${m.id} ${label} ${m.outputLines} lines (${formatDuration(age)})`));
97
+ let line = ` ${icon} #${m.id} ${label} ${m.outputLines} lines (${formatDuration(age)})`;
98
+ if (m.exitCode !== undefined && m.status !== "running")
99
+ line += ` exit=${m.exitCode}`;
100
+ lines.push(trunc(line));
90
101
  }
91
102
  return lines;
92
103
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trevonistrevon/pi-loop",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
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
@@ -42,6 +42,7 @@ interface LoopFireEvent {
42
42
  timestamp: number;
43
43
  readOnly?: boolean;
44
44
  recurring?: boolean;
45
+ autoTask?: boolean;
45
46
  }
46
47
 
47
48
  interface SessionSwitchEvent {
@@ -120,11 +121,37 @@ export default function (pi: ExtensionAPI) {
120
121
  }
121
122
  }
122
123
 
124
+ async function hasPendingTasks(): Promise<number> {
125
+ if (!tasksAvailable) return -1;
126
+ try {
127
+ const requestId = randomUUID();
128
+ const count = await new Promise<number>((resolve) => {
129
+ const timer = setTimeout(() => { unsub(); resolve(-1); }, 3000);
130
+ const unsub = pi.events.on(`tasks:rpc:pending:reply:${requestId}`, (raw: unknown) => {
131
+ unsub(); clearTimeout(timer);
132
+ const reply = raw as { success: boolean; data?: { pending: number }; error?: string };
133
+ resolve(reply.success && reply.data ? reply.data.pending : -1);
134
+ });
135
+ pi.events.emit("tasks:rpc:pending", { requestId });
136
+ });
137
+ return count;
138
+ } catch {
139
+ return -1;
140
+ }
141
+ }
142
+
123
143
  // ── Loop fire handler ──
124
144
 
125
145
  function onLoopFire(entry: LoopEntry): void {
126
146
  debug(`loop:fire #${entry.id}`, { prompt: entry.prompt.slice(0, 50) });
127
147
 
148
+ if (entry.maxFires && (entry.fireCount ?? 0) >= entry.maxFires) {
149
+ debug(`loop #${entry.id} — reached maxFires ${entry.maxFires}, expiring`);
150
+ store.update(entry.id, { status: "expired" });
151
+ return;
152
+ }
153
+ store.update(entry.id, { fireCount: (entry.fireCount ?? 0) + 1 });
154
+
128
155
  if (entry.autoTask) {
129
156
  autoCreateTask(entry).then((taskId) => {
130
157
  if (taskId) debug(`loop #${entry.id} → task #${taskId}`);
@@ -138,6 +165,7 @@ export default function (pi: ExtensionAPI) {
138
165
  timestamp: Date.now(),
139
166
  readOnly: entry.readOnly,
140
167
  recurring: entry.recurring,
168
+ autoTask: entry.autoTask,
141
169
  });
142
170
  }
143
171
 
@@ -206,7 +234,7 @@ export default function (pi: ExtensionAPI) {
206
234
 
207
235
  // ── Loop fire handler — sends a user message to re-wake the agent ──
208
236
 
209
- pi.events.on("loop:fire", (event: unknown) => {
237
+ pi.events.on("loop:fire", async (event: unknown) => {
210
238
  const data = event as LoopFireEvent;
211
239
 
212
240
  if (data.recurring && _latestCtx?.hasPendingMessages()) {
@@ -214,6 +242,14 @@ export default function (pi: ExtensionAPI) {
214
242
  return;
215
243
  }
216
244
 
245
+ if (data.autoTask) {
246
+ const pending = await hasPendingTasks();
247
+ if (pending === 0) {
248
+ debug(`loop:fire #${data.loopId} — no pending tasks, skipping`);
249
+ return;
250
+ }
251
+ }
252
+
217
253
  const triggerInfo = typeof data.trigger === "string"
218
254
  ? data.trigger
219
255
  : data.trigger?.type === "cron"
@@ -269,12 +305,26 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
269
305
  - **prompt**: what to do when the loop fires (e.g., "check if the build passed")
270
306
  - **recurring**: repeat or fire once (default: true)
271
307
  - **autoTask**: if pi-tasks is loaded, auto-create a task on each fire
272
- - **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)`,
308
+ - **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)
309
+ - **maxFires**: auto-stop after N fires — prevents infinite token burn on polling loops`,
273
310
  promptGuidelines: [
274
- "When the user asks for a loop, repeating task, periodic check, or scheduled reminder, use LoopCreatenot raw Bash for/sleep/while.",
275
- "Use LoopCreate for any 'every X seconds/minutes/hours' requests.",
311
+ "Use LoopCreate when the user asks for a repeating task, periodic check, scheduled reminder, or 'every X' never use raw Bash for/sleep/while.",
312
+ "## Choosing trigger type",
313
+ "Prefer event triggers over cron when possible — they fire exactly when needed instead of polling.",
314
+ "Use event triggers for: tool completion ('tool_execution_end'), task creation ('tasks:created'), monitor completion ('monitor:done').",
315
+ "Use cron triggers only when: the user explicitly asks for a time interval, or there's no relevant pi event to subscribe to.",
316
+ "Hybrid triggers (cron + event) give you both: event-driven responsiveness with a cron safety-net fallback.",
317
+ "## Choosing an interval",
318
+ "Default to 5m unless the user specifies differently. Use shorter intervals only when time-sensitive.",
319
+ "## maxFires — prevent infinite token burn",
320
+ "Always set maxFires on polling loops so they don't run forever. For task-continuation loops, use maxFires: 20-50.",
321
+ "When a loop fires and finds nothing to do, call LoopDelete on its own ID to stop it — don't keep polling.",
322
+ "## readOnly mode",
323
+ "Set readOnly: true for loops that only observe and report (checks, status polls). This prevents unintended changes.",
324
+ "## Task-driven workflows",
325
+ "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.'",
326
+ "When no tasks are pending, the loop skips the follow-up — no tokens burned on empty polls.",
276
327
  "After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
277
- "After creating tasks with TaskCreate, create a loop to keep working through them: LoopCreate trigger='30s' prompt='Run TaskList, pick the next available pending task, work on it. Continue until all tasks are completed.' This ensures the agent keeps making progress across turns instead of stopping after one task.",
278
328
  ],
279
329
  parameters: Type.Object({
280
330
  trigger: Type.String({ description: "Cron expression (e.g., '5m', '1h', '0 9 * * 1-5'), event source (e.g., 'tool_execution_start'), or JSON hybrid spec" }),
@@ -284,10 +334,11 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
284
334
  triggerType: Type.Optional(Type.String({ description: "cron, event, or hybrid (inferred from trigger string if omitted)", enum: ["cron", "event", "hybrid"] })),
285
335
  debounceMs: Type.Optional(Type.Number({ description: "Debounce for hybrid triggers (default: 30000)", default: 30000 })),
286
336
  readOnly: Type.Optional(Type.Boolean({ description: "Restrict the agent to read-only tools when this loop fires (default: false)", default: false })),
337
+ maxFires: Type.Optional(Type.Number({ description: "Auto-stop after N fires. Prevents infinite token burn on polling loops." })),
287
338
  }),
288
339
 
289
340
  execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
290
- const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs, readOnly } = params;
341
+ const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs, readOnly, maxFires } = params;
291
342
 
292
343
  let trigger: Trigger;
293
344
  const inferred = triggerType ?? inferTriggerType(triggerInput);
@@ -317,9 +368,26 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
317
368
  autoTask,
318
369
  selfPaced: triggerInput === "self-paced" || prompt.toLowerCase().includes("self-paced"),
319
370
  readOnly,
371
+ maxFires,
320
372
  });
321
373
 
322
374
  triggerSystem.add(entry);
375
+
376
+ if (trigger.type === "event" && trigger.source === "monitor:done" && trigger.filter) {
377
+ try {
378
+ const filterObj = JSON.parse(trigger.filter);
379
+ const monitorId = filterObj.monitorId as string | undefined;
380
+ if (monitorId) {
381
+ const monitor = monitorManager.get(monitorId);
382
+ if (monitor && monitor.status !== "running") {
383
+ debug(`loop #${entry.id} — monitor #${monitorId} already ${monitor.status}, expiring`);
384
+ triggerSystem.remove(entry.id);
385
+ store.update(entry.id, { status: "expired" });
386
+ }
387
+ }
388
+ } catch { /* filter parse failure, ignore */ }
389
+ }
390
+
323
391
  widget.update();
324
392
 
325
393
  const triggerDesc = trigger.type === "cron"
@@ -339,6 +407,16 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
339
407
  },
340
408
  });
341
409
 
410
+ function handleMonitorDoneLoop(doneLoop: LoopEntry, monitorId: string): void {
411
+ triggerSystem.add(doneLoop);
412
+ const monitor = monitorManager.get(monitorId);
413
+ if (monitor && monitor.status !== "running") {
414
+ debug(`onDone loop #${doneLoop.id} — monitor #${monitorId} already ${monitor.status}, expiring`);
415
+ triggerSystem.remove(doneLoop.id);
416
+ store.update(doneLoop.id, { status: "expired" });
417
+ }
418
+ }
419
+
342
420
  function validateTrigger(trigger: Trigger): string | null {
343
421
  if (trigger.type === "cron") {
344
422
  const parts = trigger.schedule.trim().split(/\s+/);
@@ -501,7 +579,7 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
501
579
  if (params.onDone) {
502
580
  const doneTrigger: Trigger = { type: "event", source: "monitor:done", filter: JSON.stringify({ monitorId: entry.id }) };
503
581
  const doneLoop = store.create(doneTrigger, params.onDone, { recurring: false });
504
- triggerSystem.add(doneLoop);
582
+ handleMonitorDoneLoop(doneLoop, entry.id);
505
583
  onDoneMsg = `\nonDone loop #${doneLoop.id}: fires when monitor completes — no polling needed`;
506
584
  }
507
585
 
@@ -520,7 +598,7 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
520
598
  pi.registerTool({
521
599
  name: "MonitorList",
522
600
  label: "MonitorList",
523
- description: `List all monitors with their status, command, and output line count.`,
601
+ description: `List all monitors with their status, command, exit code, output line count, and last 5 lines of buffered output.`,
524
602
  parameters: Type.Object({}),
525
603
 
526
604
  execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
@@ -532,7 +610,16 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
532
610
  const icon = m.status === "running" ? "◉" : m.status === "completed" ? "✓" : "✗";
533
611
  const age = Date.now() - m.startedAt;
534
612
  const ageStr = formatRemaining(age);
535
- lines.push(`${icon} #${m.id} [${m.status}] ${m.command.slice(0, 50)} — ${m.outputLines} lines (${ageStr})`);
613
+ let line = `${icon} #${m.id} [${m.status}] ${m.command.slice(0, 60)} — ${m.outputLines} lines (${ageStr})`;
614
+ if (m.exitCode !== undefined) line += ` exit=${m.exitCode}`;
615
+ lines.push(line);
616
+
617
+ if (m.status !== "running" && m.outputBuffer.length > 0) {
618
+ const tail = m.outputBuffer.slice(-5);
619
+ for (const out of tail) {
620
+ lines.push(` | ${out.slice(0, 100)}`);
621
+ }
622
+ }
536
623
  }
537
624
 
538
625
  return Promise.resolve(textResult(lines.join("\n")));
@@ -19,6 +19,7 @@ export class MonitorManager {
19
19
  status: "running",
20
20
  startedAt: Date.now(),
21
21
  outputLines: 0,
22
+ outputBuffer: [],
22
23
  };
23
24
 
24
25
  const abortController = new AbortController();
@@ -41,6 +42,7 @@ export class MonitorManager {
41
42
  for (const line of lines) {
42
43
  if (line.length === 0) continue;
43
44
  entry.outputLines++;
45
+ if (entry.outputBuffer.length < 200) entry.outputBuffer.push(line);
44
46
  this.pi.events.emit("monitor:output", {
45
47
  monitorId: id,
46
48
  line,
@@ -54,6 +56,7 @@ export class MonitorManager {
54
56
  for (const line of lines) {
55
57
  if (line.length === 0) continue;
56
58
  entry.outputLines++;
59
+ if (entry.outputBuffer.length < 200) entry.outputBuffer.push(line);
57
60
  this.pi.events.emit("monitor:output", {
58
61
  monitorId: id,
59
62
  line,
package/src/scheduler.ts CHANGED
@@ -58,7 +58,9 @@ export class CronScheduler {
58
58
  const _scheduleExpr = entry.trigger.type === "hybrid" ? entry.trigger.cron : (entry.trigger as { schedule: string }).schedule;
59
59
 
60
60
  const nextFire = computeNextFire(entry);
61
- const jitter = computeJitter(entry.id, entry.recurring, 30);
61
+ const minuteField = _scheduleExpr.trim().split(/\s+/)[0];
62
+ const minuteStep = minuteField.startsWith("*/") ? parseInt(minuteField.slice(2), 10) || 30 : 30;
63
+ const jitter = computeJitter(entry.id, entry.recurring, minuteStep);
62
64
  const fireTime = nextFire.getTime() + jitter;
63
65
  const now = Date.now();
64
66
 
@@ -92,6 +94,13 @@ export class CronScheduler {
92
94
  this.onFire(current);
93
95
 
94
96
  if (current.recurring) {
97
+ const fresh = this.store.get(entry.id);
98
+ if (fresh?.maxFires && (fresh.fireCount ?? 0) >= fresh.maxFires) {
99
+ this.store.update(entry.id, { status: "expired" });
100
+ this.timers.delete(entry.id);
101
+ this.fireTimes.delete(entry.id);
102
+ return;
103
+ }
95
104
  this.armTimer(current);
96
105
  } else {
97
106
  this.timers.delete(entry.id);
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 }): LoopEntry {
98
+ create(trigger: Trigger, prompt: string, opts: { recurring: boolean; autoTask?: boolean; selfPaced?: 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.`);
@@ -110,6 +110,8 @@ export class LoopStore {
110
110
  autoTask: opts.autoTask,
111
111
  selfPaced: opts.selfPaced,
112
112
  readOnly: opts.readOnly,
113
+ maxFires: opts.maxFires,
114
+ fireCount: 0,
113
115
  createdAt: now,
114
116
  updatedAt: now,
115
117
  expiresAt: now + MAX_EXPIRY_DAYS * 24 * 60 * 60 * 1000,
@@ -129,7 +131,7 @@ export class LoopStore {
129
131
  return Array.from(this.loops.values()).sort((a, b) => Number(a.id) - Number(b.id));
130
132
  }
131
133
 
132
- update(id: string, fields: { status?: LoopStatus; trigger?: Trigger; prompt?: string }): { entry: LoopEntry | undefined; changedFields: string[] } {
134
+ update(id: string, fields: { status?: LoopStatus; trigger?: Trigger; prompt?: string; fireCount?: number }): { entry: LoopEntry | undefined; changedFields: string[] } {
133
135
  return this.withLock(() => {
134
136
  const entry = this.loops.get(id);
135
137
  if (!entry) return { entry: undefined, changedFields: [] };
@@ -147,6 +149,10 @@ export class LoopStore {
147
149
  entry.prompt = fields.prompt;
148
150
  changedFields.push("prompt");
149
151
  }
152
+ if (fields.fireCount !== undefined) {
153
+ entry.fireCount = fields.fireCount;
154
+ changedFields.push("fireCount");
155
+ }
150
156
  entry.updatedAt = Date.now();
151
157
  return { entry, changedFields };
152
158
  });
@@ -177,15 +183,17 @@ export class LoopStore {
177
183
  expireEventLoops(sessionStartedAt: number): number {
178
184
  return this.withLock(() => {
179
185
  let count = 0;
180
- for (const [_id, entry] of this.loops) {
186
+ const toDelete: string[] = [];
187
+ for (const [id, entry] of this.loops) {
181
188
  if (entry.status !== "active") continue;
182
189
  if (entry.trigger.type === "event" || entry.trigger.type === "hybrid") {
183
190
  if (entry.createdAt < sessionStartedAt) {
184
- entry.status = "expired";
191
+ toDelete.push(id);
185
192
  count++;
186
193
  }
187
194
  }
188
195
  }
196
+ for (const id of toDelete) this.loops.delete(id);
189
197
  return count;
190
198
  });
191
199
  }
@@ -94,12 +94,22 @@ export class TriggerSystem {
94
94
  }
95
95
 
96
96
  private fireLoop(entry: LoopEntry): void {
97
+ if (entry.maxFires && (entry.fireCount ?? 0) >= entry.maxFires) {
98
+ this.store.update(entry.id, { status: "expired" });
99
+ this.remove(entry.id);
100
+ return;
101
+ }
102
+ this.store.update(entry.id, { fireCount: (entry.fireCount ?? 0) + 1 });
103
+
97
104
  this.lastFireTime.set(entry.id, Date.now());
98
105
  this.pi.events.emit("loop:fire", {
99
106
  loopId: entry.id,
100
107
  prompt: entry.prompt,
101
108
  trigger: entry.trigger,
102
109
  timestamp: Date.now(),
110
+ readOnly: entry.readOnly,
111
+ recurring: entry.recurring,
112
+ autoTask: entry.autoTask,
103
113
  });
104
114
 
105
115
  if (!entry.recurring) {
package/src/types.ts CHANGED
@@ -32,6 +32,8 @@ export interface LoopEntry {
32
32
  autoTask?: boolean;
33
33
  selfPaced?: boolean;
34
34
  readOnly?: boolean;
35
+ maxFires?: number;
36
+ fireCount?: number;
35
37
  }
36
38
 
37
39
  export interface LoopStoreData {
@@ -49,6 +51,7 @@ export interface MonitorEntry {
49
51
  completedAt?: number;
50
52
  exitCode?: number;
51
53
  outputLines: number;
54
+ outputBuffer: string[];
52
55
  }
53
56
 
54
57
  export interface MonitorProcess {
package/src/ui/widget.ts CHANGED
@@ -35,9 +35,10 @@ export class LoopWidget {
35
35
  if (!this.uiCtx) return;
36
36
 
37
37
  const loops = this.store.list().filter(l => l.status === "active");
38
- const monitors = this.monitorManager.list().filter(m => m.status === "running");
38
+ const monitors = this.monitorManager.list();
39
+ const hasContent = loops.length > 0 || monitors.length > 0;
39
40
 
40
- if (loops.length === 0 && monitors.length === 0) {
41
+ if (!hasContent) {
41
42
  if (this.widgetRegistered) {
42
43
  this.uiCtx.setWidget("loops", undefined);
43
44
  this.widgetRegistered = false;
@@ -66,16 +67,21 @@ export class LoopWidget {
66
67
 
67
68
  private renderWidget(tui: TUI, _theme: Theme): string[] {
68
69
  const loops = this.store.list().filter(l => l.status === "active");
69
- const monitors = this.monitorManager.list().filter(m => m.status === "running");
70
+ const runningMonitors = this.monitorManager.list().filter(m => m.status === "running");
71
+ const completedMonitors = this.monitorManager.list().filter(m => m.status === "completed");
72
+ const allMonitors = [...runningMonitors, ...completedMonitors];
70
73
  const w = tui.terminal.columns;
71
74
  const trunc = (line: string) => truncateToWidth(line, w);
72
75
 
73
76
  const lines: string[] = [];
74
- const total = loops.length + monitors.length;
77
+ const total = loops.length + allMonitors.length;
75
78
 
76
79
  if (total === 0) return [];
77
80
 
78
- lines.push(trunc(`⟳ ${loops.length} loops · ${monitors.length} monitors`));
81
+ const headerParts: string[] = [`⟳ ${loops.length} loops`];
82
+ if (runningMonitors.length > 0) headerParts.push(`${runningMonitors.length} running`);
83
+ if (completedMonitors.length > 0) headerParts.push(`${completedMonitors.length} done`);
84
+ lines.push(trunc(headerParts.join(" · ")));
79
85
 
80
86
  for (const loop of loops.slice(0, MAX_VISIBLE)) {
81
87
  const icon = "◷";
@@ -96,11 +102,13 @@ export class LoopWidget {
96
102
  lines.push(trunc(` ${icon} #${loop.id} ${loop.prompt.slice(0, 50)} → ${schedule}${timing}`));
97
103
  }
98
104
 
99
- for (const m of monitors.slice(0, Math.max(0, MAX_VISIBLE - loops.length))) {
100
- const icon = "◉";
105
+ for (const m of allMonitors.slice(0, Math.max(0, MAX_VISIBLE - loops.length))) {
106
+ const icon = m.status === "running" ? "◉" : "✓";
101
107
  const age = Date.now() - m.startedAt;
102
108
  const label = m.description || m.command.replace(/\n/g, " ").replace(/\s+/g, " ").trim().slice(0, 50);
103
- lines.push(trunc(` ${icon} #${m.id} ${label} ${m.outputLines} lines (${formatDuration(age)})`));
109
+ let line = ` ${icon} #${m.id} ${label} ${m.outputLines} lines (${formatDuration(age)})`;
110
+ if (m.exitCode !== undefined && m.status !== "running") line += ` exit=${m.exitCode}`;
111
+ lines.push(trunc(line));
104
112
  }
105
113
 
106
114
  return lines;