@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 +96 -11
- package/dist/monitor-manager.js +5 -0
- package/dist/scheduler.js +10 -1
- package/dist/store.d.ts +2 -0
- package/dist/store.js +11 -2
- package/dist/trigger-system.js +9 -0
- package/dist/types.d.ts +3 -0
- package/dist/ui/widget.js +19 -8
- package/package.json +1 -1
- package/src/index.ts +100 -11
- package/src/monitor-manager.ts +3 -0
- package/src/scheduler.ts +10 -1
- package/src/store.ts +12 -4
- package/src/trigger-system.ts +10 -0
- package/src/types.ts +3 -0
- package/src/ui/widget.ts +16 -8
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
|
-
"
|
|
242
|
-
"
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
},
|
package/dist/monitor-manager.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/trigger-system.js
CHANGED
|
@@ -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()
|
|
30
|
-
|
|
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
|
|
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 +
|
|
64
|
+
const total = loops.length + allMonitors.length;
|
|
62
65
|
if (total === 0)
|
|
63
66
|
return [];
|
|
64
|
-
|
|
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
|
|
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
|
-
|
|
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
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
|
-
"
|
|
273
|
-
"
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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")));
|
package/src/monitor-manager.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/trigger-system.ts
CHANGED
|
@@ -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()
|
|
38
|
+
const monitors = this.monitorManager.list();
|
|
39
|
+
const hasContent = loops.length > 0 || monitors.length > 0;
|
|
39
40
|
|
|
40
|
-
if (
|
|
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
|
|
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 +
|
|
77
|
+
const total = loops.length + allMonitors.length;
|
|
75
78
|
|
|
76
79
|
if (total === 0) return [];
|
|
77
80
|
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
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;
|