@trevonistrevon/pi-loop 0.2.7 → 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)
@@ -121,6 +148,8 @@ export default function (pi) {
121
148
  trigger: entry.trigger,
122
149
  timestamp: Date.now(),
123
150
  readOnly: entry.readOnly,
151
+ recurring: entry.recurring,
152
+ autoTask: entry.autoTask,
124
153
  });
125
154
  }
126
155
  // ── Session lifecycle ──
@@ -179,12 +208,19 @@ export default function (pi) {
179
208
  showPersistedLoops(isResume);
180
209
  });
181
210
  // ── Loop fire handler — sends a user message to re-wake the agent ──
182
- pi.events.on("loop:fire", (event) => {
211
+ pi.events.on("loop:fire", async (event) => {
183
212
  const data = event;
184
- if (_latestCtx?.hasPendingMessages()) {
185
- debug(`loop:fire #${data.loopId} — agent has pending messages, skipping`);
213
+ if (data.recurring && _latestCtx?.hasPendingMessages()) {
214
+ debug(`loop:fire #${data.loopId} — agent has pending messages, skipping recurring fire`);
186
215
  return;
187
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
+ }
188
224
  const triggerInfo = typeof data.trigger === "string"
189
225
  ? data.trigger
190
226
  : data.trigger?.type === "cron"
@@ -236,12 +272,26 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
236
272
  - **prompt**: what to do when the loop fires (e.g., "check if the build passed")
237
273
  - **recurring**: repeat or fire once (default: true)
238
274
  - **autoTask**: if pi-tasks is loaded, auto-create a task on each fire
239
- - **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`,
240
277
  promptGuidelines: [
241
- "When the user asks for a loop, repeating task, periodic check, or scheduled reminder, use LoopCreatenot raw Bash for/sleep/while.",
242
- "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.",
243
294
  "After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
244
- "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.",
245
295
  ],
246
296
  parameters: Type.Object({
247
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" }),
@@ -251,9 +301,10 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
251
301
  triggerType: Type.Optional(Type.String({ description: "cron, event, or hybrid (inferred from trigger string if omitted)", enum: ["cron", "event", "hybrid"] })),
252
302
  debounceMs: Type.Optional(Type.Number({ description: "Debounce for hybrid triggers (default: 30000)", default: 30000 })),
253
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." })),
254
305
  }),
255
306
  execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
256
- const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs, readOnly } = params;
307
+ const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs, readOnly, maxFires } = params;
257
308
  let trigger;
258
309
  const inferred = triggerType ?? inferTriggerType(triggerInput);
259
310
  if (inferred === "cron") {
@@ -282,8 +333,24 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
282
333
  autoTask,
283
334
  selfPaced: triggerInput === "self-paced" || prompt.toLowerCase().includes("self-paced"),
284
335
  readOnly,
336
+ maxFires,
285
337
  });
286
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
+ }
287
354
  widget.update();
288
355
  const triggerDesc = trigger.type === "cron"
289
356
  ? `schedule: ${trigger.schedule}`
@@ -298,6 +365,15 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
298
365
  `ID: ${entry.id} (use LoopDelete to cancel)`));
299
366
  },
300
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
+ }
301
377
  function validateTrigger(trigger) {
302
378
  if (trigger.type === "cron") {
303
379
  const parts = trigger.schedule.trim().split(/\s+/);
@@ -452,7 +528,7 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
452
528
  if (params.onDone) {
453
529
  const doneTrigger = { type: "event", source: "monitor:done", filter: JSON.stringify({ monitorId: entry.id }) };
454
530
  const doneLoop = store.create(doneTrigger, params.onDone, { recurring: false });
455
- triggerSystem.add(doneLoop);
531
+ handleMonitorDoneLoop(doneLoop, entry.id);
456
532
  onDoneMsg = `\nonDone loop #${doneLoop.id}: fires when monitor completes — no polling needed`;
457
533
  }
458
534
  return Promise.resolve(textResult(`Monitor #${entry.id} started: ${entry.command.slice(0, 60)}\n` +
@@ -466,7 +542,7 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
466
542
  pi.registerTool({
467
543
  name: "MonitorList",
468
544
  label: "MonitorList",
469
- 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.`,
470
546
  parameters: Type.Object({}),
471
547
  execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
472
548
  const monitors = monitorManager.list();
@@ -477,7 +553,16 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
477
553
  const icon = m.status === "running" ? "◉" : m.status === "completed" ? "✓" : "✗";
478
554
  const age = Date.now() - m.startedAt;
479
555
  const ageStr = formatRemaining(age);
480
- 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
+ }
481
566
  }
482
567
  return Promise.resolve(textResult(lines.join("\n")));
483
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.7",
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
@@ -41,6 +41,8 @@ interface LoopFireEvent {
41
41
  trigger: Trigger | string;
42
42
  timestamp: number;
43
43
  readOnly?: boolean;
44
+ recurring?: boolean;
45
+ autoTask?: boolean;
44
46
  }
45
47
 
46
48
  interface SessionSwitchEvent {
@@ -119,11 +121,37 @@ export default function (pi: ExtensionAPI) {
119
121
  }
120
122
  }
121
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
+
122
143
  // ── Loop fire handler ──
123
144
 
124
145
  function onLoopFire(entry: LoopEntry): void {
125
146
  debug(`loop:fire #${entry.id}`, { prompt: entry.prompt.slice(0, 50) });
126
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
+
127
155
  if (entry.autoTask) {
128
156
  autoCreateTask(entry).then((taskId) => {
129
157
  if (taskId) debug(`loop #${entry.id} → task #${taskId}`);
@@ -136,6 +164,8 @@ export default function (pi: ExtensionAPI) {
136
164
  trigger: entry.trigger,
137
165
  timestamp: Date.now(),
138
166
  readOnly: entry.readOnly,
167
+ recurring: entry.recurring,
168
+ autoTask: entry.autoTask,
139
169
  });
140
170
  }
141
171
 
@@ -204,14 +234,22 @@ export default function (pi: ExtensionAPI) {
204
234
 
205
235
  // ── Loop fire handler — sends a user message to re-wake the agent ──
206
236
 
207
- pi.events.on("loop:fire", (event: unknown) => {
237
+ pi.events.on("loop:fire", async (event: unknown) => {
208
238
  const data = event as LoopFireEvent;
209
239
 
210
- if (_latestCtx?.hasPendingMessages()) {
211
- debug(`loop:fire #${data.loopId} — agent has pending messages, skipping`);
240
+ if (data.recurring && _latestCtx?.hasPendingMessages()) {
241
+ debug(`loop:fire #${data.loopId} — agent has pending messages, skipping recurring fire`);
212
242
  return;
213
243
  }
214
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
+
215
253
  const triggerInfo = typeof data.trigger === "string"
216
254
  ? data.trigger
217
255
  : data.trigger?.type === "cron"
@@ -267,12 +305,26 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
267
305
  - **prompt**: what to do when the loop fires (e.g., "check if the build passed")
268
306
  - **recurring**: repeat or fire once (default: true)
269
307
  - **autoTask**: if pi-tasks is loaded, auto-create a task on each fire
270
- - **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`,
271
310
  promptGuidelines: [
272
- "When the user asks for a loop, repeating task, periodic check, or scheduled reminder, use LoopCreatenot raw Bash for/sleep/while.",
273
- "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.",
274
327
  "After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
275
- "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.",
276
328
  ],
277
329
  parameters: Type.Object({
278
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" }),
@@ -282,10 +334,11 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
282
334
  triggerType: Type.Optional(Type.String({ description: "cron, event, or hybrid (inferred from trigger string if omitted)", enum: ["cron", "event", "hybrid"] })),
283
335
  debounceMs: Type.Optional(Type.Number({ description: "Debounce for hybrid triggers (default: 30000)", default: 30000 })),
284
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." })),
285
338
  }),
286
339
 
287
340
  execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
288
- const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs, readOnly } = params;
341
+ const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs, readOnly, maxFires } = params;
289
342
 
290
343
  let trigger: Trigger;
291
344
  const inferred = triggerType ?? inferTriggerType(triggerInput);
@@ -315,9 +368,26 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
315
368
  autoTask,
316
369
  selfPaced: triggerInput === "self-paced" || prompt.toLowerCase().includes("self-paced"),
317
370
  readOnly,
371
+ maxFires,
318
372
  });
319
373
 
320
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
+
321
391
  widget.update();
322
392
 
323
393
  const triggerDesc = trigger.type === "cron"
@@ -337,6 +407,16 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
337
407
  },
338
408
  });
339
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
+
340
420
  function validateTrigger(trigger: Trigger): string | null {
341
421
  if (trigger.type === "cron") {
342
422
  const parts = trigger.schedule.trim().split(/\s+/);
@@ -499,7 +579,7 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
499
579
  if (params.onDone) {
500
580
  const doneTrigger: Trigger = { type: "event", source: "monitor:done", filter: JSON.stringify({ monitorId: entry.id }) };
501
581
  const doneLoop = store.create(doneTrigger, params.onDone, { recurring: false });
502
- triggerSystem.add(doneLoop);
582
+ handleMonitorDoneLoop(doneLoop, entry.id);
503
583
  onDoneMsg = `\nonDone loop #${doneLoop.id}: fires when monitor completes — no polling needed`;
504
584
  }
505
585
 
@@ -518,7 +598,7 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
518
598
  pi.registerTool({
519
599
  name: "MonitorList",
520
600
  label: "MonitorList",
521
- 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.`,
522
602
  parameters: Type.Object({}),
523
603
 
524
604
  execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
@@ -530,7 +610,16 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
530
610
  const icon = m.status === "running" ? "◉" : m.status === "completed" ? "✓" : "✗";
531
611
  const age = Date.now() - m.startedAt;
532
612
  const ageStr = formatRemaining(age);
533
- 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
+ }
534
623
  }
535
624
 
536
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;