@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 +93 -9
- 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 +96 -9
- 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)
|
|
@@ -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
|
-
"
|
|
243
|
-
"
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
},
|
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
|
@@ -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
|
-
"
|
|
275
|
-
"
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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")));
|
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;
|