@trevonistrevon/pi-loop 0.1.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/AGENTS.md +79 -0
- package/CHANGELOG.md +39 -0
- package/CONTRIBUTING.md +69 -0
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +614 -0
- package/dist/loop-parse.d.ts +6 -0
- package/dist/loop-parse.js +150 -0
- package/dist/monitor-manager.d.ts +13 -0
- package/dist/monitor-manager.js +137 -0
- package/dist/scheduler.d.ts +15 -0
- package/dist/scheduler.js +89 -0
- package/dist/store.d.ts +30 -0
- package/dist/store.js +195 -0
- package/dist/trigger-system.d.ts +19 -0
- package/dist/trigger-system.js +130 -0
- package/dist/types.d.ts +54 -0
- package/dist/types.js +1 -0
- package/dist/ui/widget.d.ts +28 -0
- package/dist/ui/widget.js +114 -0
- package/package.json +47 -0
- package/src/index.ts +671 -0
- package/src/loop-parse.ts +163 -0
- package/src/monitor-manager.ts +148 -0
- package/src/scheduler.ts +104 -0
- package/src/store.ts +189 -0
- package/src/trigger-system.ts +132 -0
- package/src/types.ts +59 -0
- package/src/ui/widget.ts +132 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @trevonistrevon/pi-loop — A pi extension providing cron/event-based agent re-wake loops and background process monitoring.
|
|
3
|
+
*
|
|
4
|
+
* Tools:
|
|
5
|
+
* LoopCreate — Create a scheduled or event-triggered re-wake loop
|
|
6
|
+
* LoopList — List all active loops with status and next-fire times
|
|
7
|
+
* LoopDelete — Delete or pause a loop by ID
|
|
8
|
+
* MonitorCreate — Start a background command that streams output via pi events
|
|
9
|
+
* MonitorList — List running monitors
|
|
10
|
+
* MonitorStop — Stop a running monitor
|
|
11
|
+
*
|
|
12
|
+
* Commands:
|
|
13
|
+
* /loop — Schedule a re-wake loop: /loop [interval] [prompt]
|
|
14
|
+
* /loops — Interactive menu: view, create, cancel, settings
|
|
15
|
+
*/
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
import { join, resolve } from "node:path";
|
|
18
|
+
import { Type } from "typebox";
|
|
19
|
+
import { parseInterval } from "./loop-parse.js";
|
|
20
|
+
import { MonitorManager } from "./monitor-manager.js";
|
|
21
|
+
import { CronScheduler } from "./scheduler.js";
|
|
22
|
+
import { LoopStore } from "./store.js";
|
|
23
|
+
import { TriggerSystem } from "./trigger-system.js";
|
|
24
|
+
import { LoopWidget } from "./ui/widget.js";
|
|
25
|
+
const DEBUG = !!process.env.PI_LOOP_DEBUG;
|
|
26
|
+
function debug(...args) {
|
|
27
|
+
if (DEBUG)
|
|
28
|
+
console.error("[pi-loop]", ...args);
|
|
29
|
+
}
|
|
30
|
+
function textResult(msg) {
|
|
31
|
+
return { content: [{ type: "text", text: msg }], details: undefined };
|
|
32
|
+
}
|
|
33
|
+
const LOOP_TOOL_NAMES = new Set(["LoopCreate", "LoopList", "LoopDelete", "MonitorCreate", "MonitorList", "MonitorStop"]);
|
|
34
|
+
const REMINDER_INTERVAL = 3;
|
|
35
|
+
const SYSTEM_REMINDER_TEMPLATE = `<system-reminder>
|
|
36
|
+
Scheduled loop "%propmpt%" fired. Trigger: %trigger_info%.
|
|
37
|
+
[loop:%loop_id%]
|
|
38
|
+
</system-reminder>`;
|
|
39
|
+
export default function (pi) {
|
|
40
|
+
const piLoopEnv = process.env.PI_LOOP;
|
|
41
|
+
const piLoopScope = process.env.PI_LOOP_SCOPE;
|
|
42
|
+
let loopScope = piLoopScope ?? "session";
|
|
43
|
+
function resolveStorePath(sessionId) {
|
|
44
|
+
if (piLoopEnv === "off")
|
|
45
|
+
return undefined;
|
|
46
|
+
if (piLoopEnv?.startsWith("/"))
|
|
47
|
+
return piLoopEnv;
|
|
48
|
+
if (piLoopEnv?.startsWith("."))
|
|
49
|
+
return resolve(piLoopEnv);
|
|
50
|
+
if (piLoopEnv)
|
|
51
|
+
return piLoopEnv;
|
|
52
|
+
if (loopScope === "memory")
|
|
53
|
+
return undefined;
|
|
54
|
+
if (loopScope === "session" && sessionId) {
|
|
55
|
+
return join(process.cwd(), ".pi", "loops", `loops-${sessionId}.json`);
|
|
56
|
+
}
|
|
57
|
+
if (loopScope === "session")
|
|
58
|
+
return undefined;
|
|
59
|
+
return join(process.cwd(), ".pi", "loops", "loops.json");
|
|
60
|
+
}
|
|
61
|
+
let store = new LoopStore(resolveStorePath());
|
|
62
|
+
const monitorManager = new MonitorManager(pi);
|
|
63
|
+
let scheduler;
|
|
64
|
+
let triggerSystem;
|
|
65
|
+
const widget = new LoopWidget(store, undefined, monitorManager);
|
|
66
|
+
scheduler = new CronScheduler(store, onLoopFire);
|
|
67
|
+
widget.setScheduler(scheduler);
|
|
68
|
+
triggerSystem = new TriggerSystem(pi, scheduler);
|
|
69
|
+
// ── pi-tasks integration ──
|
|
70
|
+
let tasksAvailable = false;
|
|
71
|
+
const _PROTOCOL_VERSION = 1;
|
|
72
|
+
function checkTasksVersion() {
|
|
73
|
+
const requestId = randomUUID();
|
|
74
|
+
const timer = setTimeout(() => { unsub(); }, 5000);
|
|
75
|
+
const unsub = pi.events.on(`tasks:rpc:ping:reply:${requestId}`, (raw) => {
|
|
76
|
+
unsub();
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
const remoteVersion = raw?.data?.version;
|
|
79
|
+
if (remoteVersion !== undefined)
|
|
80
|
+
tasksAvailable = true;
|
|
81
|
+
});
|
|
82
|
+
pi.events.emit("tasks:rpc:ping", { requestId });
|
|
83
|
+
}
|
|
84
|
+
checkTasksVersion();
|
|
85
|
+
pi.events.on("tasks:ready", () => checkTasksVersion());
|
|
86
|
+
async function autoCreateTask(entry) {
|
|
87
|
+
if (!tasksAvailable || !entry.autoTask)
|
|
88
|
+
return undefined;
|
|
89
|
+
try {
|
|
90
|
+
const requestId = randomUUID();
|
|
91
|
+
const taskId = await new Promise((resolve, _reject) => {
|
|
92
|
+
const timer = setTimeout(() => { unsub(); resolve(undefined); }, 5000);
|
|
93
|
+
const unsub = pi.events.on(`tasks:rpc:create:reply:${requestId}`, (raw) => {
|
|
94
|
+
unsub();
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
const reply = raw;
|
|
97
|
+
if (reply.success && reply.data)
|
|
98
|
+
resolve(reply.data.id);
|
|
99
|
+
else
|
|
100
|
+
resolve(undefined);
|
|
101
|
+
});
|
|
102
|
+
pi.events.emit("tasks:rpc:create", {
|
|
103
|
+
requestId,
|
|
104
|
+
subject: entry.prompt.slice(0, 80),
|
|
105
|
+
description: `Auto-created from loop #${entry.id}`,
|
|
106
|
+
metadata: { loopId: entry.id, trigger: entry.trigger },
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
return taskId;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// ── Loop fire handler ──
|
|
116
|
+
function onLoopFire(entry) {
|
|
117
|
+
debug(`loop:fire #${entry.id}`, { prompt: entry.prompt.slice(0, 50) });
|
|
118
|
+
if (entry.autoTask) {
|
|
119
|
+
autoCreateTask(entry).then((taskId) => {
|
|
120
|
+
if (taskId)
|
|
121
|
+
debug(`loop #${entry.id} → task #${taskId}`);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
pi.events.emit("loop:fire", {
|
|
125
|
+
loopId: entry.id,
|
|
126
|
+
prompt: entry.prompt,
|
|
127
|
+
trigger: entry.trigger,
|
|
128
|
+
timestamp: Date.now(),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
// ── Session lifecycle ──
|
|
132
|
+
let storeUpgraded = false;
|
|
133
|
+
let persistedShown = false;
|
|
134
|
+
let _latestCtx;
|
|
135
|
+
function upgradeStoreIfNeeded(ctx) {
|
|
136
|
+
if (storeUpgraded)
|
|
137
|
+
return;
|
|
138
|
+
if (loopScope === "session" && !piLoopEnv) {
|
|
139
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
140
|
+
const path = resolveStorePath(sessionId);
|
|
141
|
+
store = new LoopStore(path);
|
|
142
|
+
widget.setStore(store);
|
|
143
|
+
scheduler = new CronScheduler(store, onLoopFire);
|
|
144
|
+
widget.setScheduler(scheduler);
|
|
145
|
+
triggerSystem = new TriggerSystem(pi, scheduler);
|
|
146
|
+
}
|
|
147
|
+
storeUpgraded = true;
|
|
148
|
+
}
|
|
149
|
+
function showPersistedLoops(_isResume = false) {
|
|
150
|
+
if (persistedShown)
|
|
151
|
+
return;
|
|
152
|
+
persistedShown = true;
|
|
153
|
+
const loops = store.list();
|
|
154
|
+
if (loops.length > 0) {
|
|
155
|
+
store.clearExpired();
|
|
156
|
+
triggerSystem.start();
|
|
157
|
+
widget.update();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
pi.on("turn_start", async (_event, ctx) => {
|
|
161
|
+
_latestCtx = ctx;
|
|
162
|
+
widget.setUICtx(ctx.ui);
|
|
163
|
+
upgradeStoreIfNeeded(ctx);
|
|
164
|
+
});
|
|
165
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
166
|
+
_latestCtx = ctx;
|
|
167
|
+
widget.setUICtx(ctx.ui);
|
|
168
|
+
upgradeStoreIfNeeded(ctx);
|
|
169
|
+
showPersistedLoops();
|
|
170
|
+
});
|
|
171
|
+
pi.on("session_switch", async (event, ctx) => {
|
|
172
|
+
_latestCtx = ctx;
|
|
173
|
+
widget.setUICtx(ctx.ui);
|
|
174
|
+
triggerSystem.stop();
|
|
175
|
+
const isResume = event?.reason === "resume";
|
|
176
|
+
storeUpgraded = false;
|
|
177
|
+
persistedShown = false;
|
|
178
|
+
if (!isResume && loopScope === "memory") {
|
|
179
|
+
store.clearAll();
|
|
180
|
+
}
|
|
181
|
+
upgradeStoreIfNeeded(ctx);
|
|
182
|
+
showPersistedLoops(isResume);
|
|
183
|
+
});
|
|
184
|
+
// ── System-reminder injection for loop fires ──
|
|
185
|
+
let currentTurn = 0;
|
|
186
|
+
let lastLoopToolUseTurn = 0;
|
|
187
|
+
let reminderInjectedThisCycle = false;
|
|
188
|
+
const pendingReminders = [];
|
|
189
|
+
pi.on("loop:fire", (data) => {
|
|
190
|
+
const triggerInfo = typeof data.trigger === "string"
|
|
191
|
+
? data.trigger
|
|
192
|
+
: data.trigger?.type === "cron"
|
|
193
|
+
? `schedule: ${data.trigger.schedule}`
|
|
194
|
+
: data.trigger?.type === "event"
|
|
195
|
+
? `event: ${data.trigger.source}`
|
|
196
|
+
: `hybrid`;
|
|
197
|
+
const reminder = SYSTEM_REMINDER_TEMPLATE
|
|
198
|
+
.replace("%prompt%", data.prompt || "loop fired")
|
|
199
|
+
.replace("%trigger_info%", triggerInfo)
|
|
200
|
+
.replace("%loop_id%", data.loopId || "unknown");
|
|
201
|
+
pendingReminders.push(reminder);
|
|
202
|
+
});
|
|
203
|
+
pi.on("turn_start", async () => {
|
|
204
|
+
currentTurn++;
|
|
205
|
+
});
|
|
206
|
+
pi.on("tool_result", async (event) => {
|
|
207
|
+
if (LOOP_TOOL_NAMES.has(event.toolName)) {
|
|
208
|
+
lastLoopToolUseTurn = currentTurn;
|
|
209
|
+
reminderInjectedThisCycle = false;
|
|
210
|
+
return {};
|
|
211
|
+
}
|
|
212
|
+
if (currentTurn - lastLoopToolUseTurn < REMINDER_INTERVAL || reminderInjectedThisCycle) {
|
|
213
|
+
return {};
|
|
214
|
+
}
|
|
215
|
+
if (pendingReminders.length === 0)
|
|
216
|
+
return {};
|
|
217
|
+
reminderInjectedThisCycle = true;
|
|
218
|
+
lastLoopToolUseTurn = currentTurn;
|
|
219
|
+
const reminder = pendingReminders.shift();
|
|
220
|
+
return {
|
|
221
|
+
content: [...event.content, { type: "text", text: reminder }],
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
// ──────────────────────────────────────────────────
|
|
225
|
+
// Tool 1: LoopCreate
|
|
226
|
+
// ──────────────────────────────────────────────────
|
|
227
|
+
pi.registerTool({
|
|
228
|
+
name: "LoopCreate",
|
|
229
|
+
label: "LoopCreate",
|
|
230
|
+
description: `Create a scheduled repeating task (loop) that runs a prompt on a timer or when an event fires.
|
|
231
|
+
|
|
232
|
+
Use this tool whenever the user asks to:
|
|
233
|
+
- "create a loop" to check something periodically
|
|
234
|
+
- "run every X seconds/minutes/hours"
|
|
235
|
+
- "remind me to check..."
|
|
236
|
+
- "watch for..." or "when X happens, do Y"
|
|
237
|
+
- "schedule a recurring check"
|
|
238
|
+
- set up a periodic monitor or poller
|
|
239
|
+
|
|
240
|
+
DO NOT use raw Bash loops (for/sleep/while). Use LoopCreate instead — it integrates with the session lifecycle, survives across turns, and the scheduler handles timing.
|
|
241
|
+
|
|
242
|
+
## When NOT to Use
|
|
243
|
+
|
|
244
|
+
Skip this tool when the task is a one-off check (just do it directly) or when the user wants a purely reactive hook.
|
|
245
|
+
|
|
246
|
+
## Trigger Types
|
|
247
|
+
|
|
248
|
+
- **cron**: time-based. "30s" (rounded to 1m), "5m", "2h", "1d", or full cron like "0 9 * * 1-5"
|
|
249
|
+
- **event**: fires on pi events like "tool_execution_start", "before_agent_start"
|
|
250
|
+
- **hybrid**: both cron + event with debounce
|
|
251
|
+
|
|
252
|
+
## Parameters
|
|
253
|
+
|
|
254
|
+
- **trigger**: interval like "30s", "5m", "2h", event source, or hybrid spec
|
|
255
|
+
- **prompt**: what to do when the loop fires (e.g., "check if the build passed")
|
|
256
|
+
- **recurring**: repeat or fire once (default: true)
|
|
257
|
+
- **autoTask**: if pi-tasks is loaded, auto-create a task on each fire
|
|
258
|
+
- **triggerType**: "cron", "event", or "hybrid" (inferred if omitted)`,
|
|
259
|
+
promptGuidelines: [
|
|
260
|
+
"When the user asks for a loop, repeating task, periodic check, or scheduled reminder, use LoopCreate — not raw Bash for/sleep/while.",
|
|
261
|
+
"Use LoopCreate for any 'every X seconds/minutes/hours' requests.",
|
|
262
|
+
"After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
|
|
263
|
+
],
|
|
264
|
+
parameters: Type.Object({
|
|
265
|
+
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" }),
|
|
266
|
+
prompt: Type.String({ description: "Prompt to run when the loop fires" }),
|
|
267
|
+
recurring: Type.Optional(Type.Boolean({ description: "Whether loop repeats (default: true)", default: true })),
|
|
268
|
+
autoTask: Type.Optional(Type.Boolean({ description: "Auto-create pi-tasks task on fire", default: false })),
|
|
269
|
+
triggerType: Type.Optional(Type.String({ description: "cron, event, or hybrid (inferred from trigger string if omitted)", enum: ["cron", "event", "hybrid"] })),
|
|
270
|
+
debounceMs: Type.Optional(Type.Number({ description: "Debounce for hybrid triggers (default: 30000)", default: 30000 })),
|
|
271
|
+
}),
|
|
272
|
+
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
273
|
+
const { trigger: triggerInput, prompt, recurring, autoTask, triggerType, debounceMs } = params;
|
|
274
|
+
let trigger;
|
|
275
|
+
const inferred = triggerType ?? inferTriggerType(triggerInput);
|
|
276
|
+
if (inferred === "cron") {
|
|
277
|
+
const parsed = parseInterval(triggerInput);
|
|
278
|
+
trigger = { type: "cron", schedule: parsed.cron };
|
|
279
|
+
}
|
|
280
|
+
else if (inferred === "event") {
|
|
281
|
+
trigger = { type: "event", source: triggerInput };
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
const cronPart = triggerInput.match(/cron:?\s*(\S+)/)?.[1] || triggerInput;
|
|
285
|
+
const eventPart = triggerInput.match(/event:?\s*(\S+)/)?.[1];
|
|
286
|
+
const parsed = parseInterval(cronPart);
|
|
287
|
+
trigger = {
|
|
288
|
+
type: "hybrid",
|
|
289
|
+
cron: parsed.cron,
|
|
290
|
+
event: { source: eventPart || "tool_execution_start" },
|
|
291
|
+
debounceMs: debounceMs ?? 30000,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
const entry = store.create(trigger, prompt, {
|
|
295
|
+
recurring: recurring ?? (inferred !== "event"),
|
|
296
|
+
autoTask,
|
|
297
|
+
selfPaced: triggerInput === "self-paced" || prompt.toLowerCase().includes("self-paced"),
|
|
298
|
+
});
|
|
299
|
+
triggerSystem.add(entry);
|
|
300
|
+
widget.update();
|
|
301
|
+
const triggerDesc = trigger.type === "cron"
|
|
302
|
+
? `schedule: ${trigger.schedule}`
|
|
303
|
+
: trigger.type === "event"
|
|
304
|
+
? `event: ${trigger.source}`
|
|
305
|
+
: `hybrid: cron ${trigger.cron} + event ${trigger.event.source}`;
|
|
306
|
+
return Promise.resolve(textResult(`Loop #${entry.id} created: ${entry.prompt.slice(0, 60)}\n` +
|
|
307
|
+
`Trigger: ${triggerDesc}\n` +
|
|
308
|
+
`Recurring: ${entry.recurring}\n` +
|
|
309
|
+
(entry.autoTask ? `Auto-task: enabled\n` : "") +
|
|
310
|
+
(tasksAvailable ? "" : "(pi-tasks not detected — autoTask will have no effect)\n") +
|
|
311
|
+
`ID: ${entry.id} (use LoopDelete to cancel)`));
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
function inferTriggerType(input) {
|
|
315
|
+
if (input.includes("hybrid") || (input.includes("cron") && input.includes("event")))
|
|
316
|
+
return "hybrid";
|
|
317
|
+
if (/^\d+\s*[smhd]$/i.test(input.trim()))
|
|
318
|
+
return "cron";
|
|
319
|
+
if (/^(\*|\d+)/.test(input.trim()) && input.trim().split(/\s+/).length === 5)
|
|
320
|
+
return "cron";
|
|
321
|
+
return "event";
|
|
322
|
+
}
|
|
323
|
+
// ──────────────────────────────────────────────────
|
|
324
|
+
// Tool 2: LoopList
|
|
325
|
+
// ──────────────────────────────────────────────────
|
|
326
|
+
pi.registerTool({
|
|
327
|
+
name: "LoopList",
|
|
328
|
+
label: "LoopList",
|
|
329
|
+
description: `List all active scheduled loops with their IDs, triggers, and next-fire times.
|
|
330
|
+
|
|
331
|
+
Use this before creating new loops to avoid duplicates, or to find IDs for LoopDelete.`,
|
|
332
|
+
parameters: Type.Object({}),
|
|
333
|
+
execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
|
334
|
+
const loops = store.list();
|
|
335
|
+
if (loops.length === 0)
|
|
336
|
+
return Promise.resolve(textResult("No loops configured. Use LoopCreate to set up a schedule."));
|
|
337
|
+
const lines = [];
|
|
338
|
+
for (const entry of loops) {
|
|
339
|
+
const triggerDesc = entry.trigger.type === "cron"
|
|
340
|
+
? `cron: ${entry.trigger.schedule}`
|
|
341
|
+
: entry.trigger.type === "event"
|
|
342
|
+
? `event: ${entry.trigger.source}`
|
|
343
|
+
: `hybrid: ${entry.trigger.cron} + ${entry.trigger.event.source}`;
|
|
344
|
+
const nextFire = entry.trigger.type !== "event"
|
|
345
|
+
? scheduler.nextFire(entry.id)
|
|
346
|
+
: undefined;
|
|
347
|
+
const statusIcon = entry.status === "active" ? "⟳" : entry.status === "paused" ? "⏸" : "✗";
|
|
348
|
+
let line = `${statusIcon} #${entry.id} [${entry.status}] ${entry.prompt.slice(0, 60)}`;
|
|
349
|
+
line += ` (${triggerDesc})`;
|
|
350
|
+
if (nextFire) {
|
|
351
|
+
const remaining = Math.max(0, nextFire - Date.now());
|
|
352
|
+
line += ` next: ${formatRemaining(remaining)}`;
|
|
353
|
+
}
|
|
354
|
+
if (entry.autoTask)
|
|
355
|
+
line += " [auto-task]";
|
|
356
|
+
lines.push(line);
|
|
357
|
+
}
|
|
358
|
+
return Promise.resolve(textResult(lines.join("\n")));
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
function formatRemaining(ms) {
|
|
362
|
+
if (ms < 60000)
|
|
363
|
+
return `${Math.round(ms / 1000)}s`;
|
|
364
|
+
if (ms < 3600000)
|
|
365
|
+
return `${Math.round(ms / 60000)}m`;
|
|
366
|
+
return `${Math.round(ms / 3600000)}h`;
|
|
367
|
+
}
|
|
368
|
+
// ──────────────────────────────────────────────────
|
|
369
|
+
// Tool 3: LoopDelete
|
|
370
|
+
// ──────────────────────────────────────────────────
|
|
371
|
+
pi.registerTool({
|
|
372
|
+
name: "LoopDelete",
|
|
373
|
+
label: "LoopDelete",
|
|
374
|
+
description: `Delete or pause a loop by its ID.
|
|
375
|
+
|
|
376
|
+
Use "pause" to temporarily stop a loop without removing it. Use "delete" to permanently remove it.`,
|
|
377
|
+
parameters: Type.Object({
|
|
378
|
+
id: Type.String({ description: "Loop ID to delete or pause" }),
|
|
379
|
+
action: Type.Optional(Type.String({ description: "delete or pause (default: delete)", enum: ["delete", "pause"], default: "delete" })),
|
|
380
|
+
}),
|
|
381
|
+
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
382
|
+
const { id, action } = params;
|
|
383
|
+
if (action === "pause") {
|
|
384
|
+
const result = store.update(id, { status: "paused" });
|
|
385
|
+
if (!result.entry)
|
|
386
|
+
return Promise.resolve(textResult(`Loop #${id} not found`));
|
|
387
|
+
triggerSystem.remove(id);
|
|
388
|
+
widget.update();
|
|
389
|
+
return Promise.resolve(textResult(`Loop #${id} paused`));
|
|
390
|
+
}
|
|
391
|
+
triggerSystem.remove(id);
|
|
392
|
+
const deleted = store.delete(id);
|
|
393
|
+
widget.update();
|
|
394
|
+
if (deleted)
|
|
395
|
+
return Promise.resolve(textResult(`Loop #${id} deleted`));
|
|
396
|
+
return Promise.resolve(textResult(`Loop #${id} not found`));
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
// ──────────────────────────────────────────────────
|
|
400
|
+
// Tool 4: MonitorCreate
|
|
401
|
+
// ──────────────────────────────────────────────────
|
|
402
|
+
pi.registerTool({
|
|
403
|
+
name: "MonitorCreate",
|
|
404
|
+
label: "MonitorCreate",
|
|
405
|
+
description: `Start a background command and stream its output via pi events.
|
|
406
|
+
|
|
407
|
+
The monitor runs a shell command in the background. Each output line is emitted as a "monitor:output" pi event. Use this to watch logs, tail files, poll APIs, etc.
|
|
408
|
+
|
|
409
|
+
Events emitted:
|
|
410
|
+
- "monitor:output" — { monitorId, line, timestamp } for each output line
|
|
411
|
+
- "monitor:done" — { monitorId, exitCode } on clean exit
|
|
412
|
+
- "monitor:error" — { monitorId, error } on failure`,
|
|
413
|
+
parameters: Type.Object({
|
|
414
|
+
command: Type.String({ description: "Shell command to run in background" }),
|
|
415
|
+
description: Type.Optional(Type.String({ description: "Human-readable description" })),
|
|
416
|
+
timeout: Type.Optional(Type.Number({ description: "Auto-stop after N ms (default: 300000, 0 = no timeout)", default: 300000 })),
|
|
417
|
+
}),
|
|
418
|
+
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
419
|
+
if (monitorManager.list().filter(m => m.status === "running").length >= 25) {
|
|
420
|
+
return Promise.resolve(textResult("Maximum of 25 running monitors reached. Stop some before creating new ones."));
|
|
421
|
+
}
|
|
422
|
+
const entry = monitorManager.create(params.command, params.description, params.timeout);
|
|
423
|
+
widget.update();
|
|
424
|
+
return Promise.resolve(textResult(`Monitor #${entry.id} started: ${entry.command.slice(0, 60)}\n` +
|
|
425
|
+
`Output stream: monitor:output (monitorId: ${entry.id})\n` +
|
|
426
|
+
`Timeout: ${params.timeout ? `${params.timeout / 1000}s` : "none"}`));
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
// ──────────────────────────────────────────────────
|
|
430
|
+
// Tool 5: MonitorList
|
|
431
|
+
// ──────────────────────────────────────────────────
|
|
432
|
+
pi.registerTool({
|
|
433
|
+
name: "MonitorList",
|
|
434
|
+
label: "MonitorList",
|
|
435
|
+
description: `List all monitors with their status, command, and output line count.`,
|
|
436
|
+
parameters: Type.Object({}),
|
|
437
|
+
execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
|
438
|
+
const monitors = monitorManager.list();
|
|
439
|
+
if (monitors.length === 0)
|
|
440
|
+
return Promise.resolve(textResult("No monitors running."));
|
|
441
|
+
const lines = [];
|
|
442
|
+
for (const m of monitors) {
|
|
443
|
+
const icon = m.status === "running" ? "◉" : m.status === "completed" ? "✓" : "✗";
|
|
444
|
+
const age = Date.now() - m.startedAt;
|
|
445
|
+
const ageStr = formatRemaining(age);
|
|
446
|
+
lines.push(`${icon} #${m.id} [${m.status}] ${m.command.slice(0, 50)} — ${m.outputLines} lines (${ageStr})`);
|
|
447
|
+
}
|
|
448
|
+
return Promise.resolve(textResult(lines.join("\n")));
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
// ──────────────────────────────────────────────────
|
|
452
|
+
// Tool 6: MonitorStop
|
|
453
|
+
// ──────────────────────────────────────────────────
|
|
454
|
+
pi.registerTool({
|
|
455
|
+
name: "MonitorStop",
|
|
456
|
+
label: "MonitorStop",
|
|
457
|
+
description: `Stop a running monitor. Sends SIGTERM, waits 5s, then SIGKILL.
|
|
458
|
+
|
|
459
|
+
Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
460
|
+
parameters: Type.Object({
|
|
461
|
+
monitorId: Type.String({ description: "Monitor ID to stop" }),
|
|
462
|
+
}),
|
|
463
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
464
|
+
const stopped = await monitorManager.stop(params.monitorId);
|
|
465
|
+
widget.update();
|
|
466
|
+
if (stopped)
|
|
467
|
+
return textResult(`Monitor #${params.monitorId} stopped`);
|
|
468
|
+
return textResult(`Monitor #${params.monitorId} not found or not running`);
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
// ──────────────────────────────────────────────────
|
|
472
|
+
// /loop command
|
|
473
|
+
// ──────────────────────────────────────────────────
|
|
474
|
+
pi.registerCommand("loop", {
|
|
475
|
+
description: "Create a repeating scheduled task: /loop [interval] [prompt]. E.g., /loop 5m check the deploy, /loop 30s am I still here",
|
|
476
|
+
handler: async (args, ctx) => {
|
|
477
|
+
const trimmed = args.trim();
|
|
478
|
+
const ui = ctx.ui;
|
|
479
|
+
if (!trimmed) {
|
|
480
|
+
const choice = await ui.select("Loop", [
|
|
481
|
+
"Create scheduled loop",
|
|
482
|
+
"Create event-triggered loop",
|
|
483
|
+
"View active loops",
|
|
484
|
+
"Settings",
|
|
485
|
+
]);
|
|
486
|
+
if (!choice)
|
|
487
|
+
return;
|
|
488
|
+
if (choice.startsWith("Create scheduled"))
|
|
489
|
+
return scheduleLoop(ui);
|
|
490
|
+
if (choice.startsWith("Create event"))
|
|
491
|
+
return eventLoop(ui);
|
|
492
|
+
if (choice.startsWith("View active"))
|
|
493
|
+
return viewLoops(ui);
|
|
494
|
+
return settings(ui);
|
|
495
|
+
}
|
|
496
|
+
const intervalMatch = trimmed.match(/^(\d+\s*[smhdS]\b)/i);
|
|
497
|
+
if (intervalMatch) {
|
|
498
|
+
const interval = intervalMatch[1];
|
|
499
|
+
const prompt = trimmed.slice(intervalMatch[0].length).trim();
|
|
500
|
+
if (!prompt) {
|
|
501
|
+
ui.notify("Provide a prompt after the interval, e.g., /loop 5m check the deploy", "warning");
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
const parsed = parseInterval(interval);
|
|
506
|
+
const trigger = { type: "cron", schedule: parsed.cron };
|
|
507
|
+
const entry = store.create(trigger, prompt, { recurring: true });
|
|
508
|
+
triggerSystem.add(entry);
|
|
509
|
+
widget.update();
|
|
510
|
+
ui.notify(`Loop #${entry.id} created: every ${parsed.description} — ${prompt.slice(0, 50)}`, "info");
|
|
511
|
+
}
|
|
512
|
+
catch (err) {
|
|
513
|
+
ui.notify(err.message, "error");
|
|
514
|
+
}
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const choice = await ui.select("Loop mode", [
|
|
518
|
+
`Scheduled: "${trimmed.slice(0, 50)}"`,
|
|
519
|
+
`Event-triggered: "${trimmed.slice(0, 50)}"`,
|
|
520
|
+
`Self-paced: "${trimmed.slice(0, 50)}"`,
|
|
521
|
+
]);
|
|
522
|
+
if (!choice)
|
|
523
|
+
return;
|
|
524
|
+
if (choice.startsWith("Event"))
|
|
525
|
+
return eventLoop(ui, trimmed);
|
|
526
|
+
return scheduleLoop(ui, trimmed);
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
async function scheduleLoop(ui, prompt) {
|
|
530
|
+
const p = prompt || await ui.input("Prompt (what should the agent check?)");
|
|
531
|
+
if (!p)
|
|
532
|
+
return;
|
|
533
|
+
const interval = await ui.input("Interval (e.g., 5m, 2h, 1d)");
|
|
534
|
+
if (!interval)
|
|
535
|
+
return;
|
|
536
|
+
try {
|
|
537
|
+
const parsed = parseInterval(interval);
|
|
538
|
+
const trigger = { type: "cron", schedule: parsed.cron };
|
|
539
|
+
const entry = store.create(trigger, p, { recurring: true });
|
|
540
|
+
triggerSystem.add(entry);
|
|
541
|
+
widget.update();
|
|
542
|
+
ui.notify(`Loop #${entry.id} created: every ${parsed.description}`, "info");
|
|
543
|
+
}
|
|
544
|
+
catch (err) {
|
|
545
|
+
ui.notify(err.message, "error");
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
async function eventLoop(ui, prompt) {
|
|
549
|
+
const p = prompt || await ui.input("Prompt");
|
|
550
|
+
if (!p)
|
|
551
|
+
return;
|
|
552
|
+
const source = await ui.input("Pi event source (e.g., tool_execution_start, before_agent_start)");
|
|
553
|
+
if (!source)
|
|
554
|
+
return;
|
|
555
|
+
const trigger = { type: "event", source };
|
|
556
|
+
const entry = store.create(trigger, p, { recurring: true });
|
|
557
|
+
triggerSystem.add(entry);
|
|
558
|
+
widget.update();
|
|
559
|
+
ui.notify(`Event loop #${entry.id} created: fires on "${source}"`, "info");
|
|
560
|
+
}
|
|
561
|
+
async function viewLoops(ui) {
|
|
562
|
+
const loops = store.list();
|
|
563
|
+
if (loops.length === 0) {
|
|
564
|
+
await ui.select("No active loops", ["← Back"]);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const choices = loops.map((l) => {
|
|
568
|
+
const icon = l.status === "active" ? "⟳" : l.status === "paused" ? "⏸" : "✗";
|
|
569
|
+
const triggerDesc = l.trigger.type === "cron" ? `cron: ${l.trigger.schedule}` : l.trigger.type === "event" ? `event: ${l.trigger.source}` : `hybrid: ${l.trigger.cron}`;
|
|
570
|
+
return `${icon} #${l.id} [${l.status}] ${l.prompt.slice(0, 50)} (${triggerDesc})`;
|
|
571
|
+
});
|
|
572
|
+
choices.push("← Back");
|
|
573
|
+
const selected = await ui.select("Active Loops", choices);
|
|
574
|
+
if (!selected || selected === "← Back")
|
|
575
|
+
return;
|
|
576
|
+
const match = selected.match(/#(\d+)/);
|
|
577
|
+
if (match) {
|
|
578
|
+
const entry = store.get(match[1]);
|
|
579
|
+
if (entry) {
|
|
580
|
+
const actions = ["✗ Delete"];
|
|
581
|
+
if (entry.status === "active")
|
|
582
|
+
actions.unshift("⏸ Pause");
|
|
583
|
+
else if (entry.status === "paused")
|
|
584
|
+
actions.unshift("▶ Resume");
|
|
585
|
+
actions.push("← Back");
|
|
586
|
+
const action = await ui.select(`#${entry.id}: ${entry.prompt}\nTrigger: ${JSON.stringify(entry.trigger)}`, actions);
|
|
587
|
+
if (action === "✗ Delete") {
|
|
588
|
+
triggerSystem.remove(entry.id);
|
|
589
|
+
store.delete(entry.id);
|
|
590
|
+
widget.update();
|
|
591
|
+
ui.notify(`Loop #${entry.id} deleted`, "info");
|
|
592
|
+
}
|
|
593
|
+
else if (action === "⏸ Pause") {
|
|
594
|
+
store.update(entry.id, { status: "paused" });
|
|
595
|
+
triggerSystem.remove(entry.id);
|
|
596
|
+
widget.update();
|
|
597
|
+
ui.notify(`Loop #${entry.id} paused`, "info");
|
|
598
|
+
}
|
|
599
|
+
else if (action === "▶ Resume") {
|
|
600
|
+
store.update(entry.id, { status: "active" });
|
|
601
|
+
triggerSystem.add(entry);
|
|
602
|
+
widget.update();
|
|
603
|
+
ui.notify(`Loop #${entry.id} resumed`, "info");
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return viewLoops(ui);
|
|
608
|
+
}
|
|
609
|
+
async function settings(ui) {
|
|
610
|
+
const loops = store.list();
|
|
611
|
+
const active = loops.filter(l => l.status === "active").length;
|
|
612
|
+
ui.notify(`${active}/${loops.length} active loops (max 25)`, "info");
|
|
613
|
+
}
|
|
614
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function parseInterval(input: string): {
|
|
2
|
+
cron: string;
|
|
3
|
+
description: string;
|
|
4
|
+
};
|
|
5
|
+
export declare function cronToNextFire(cronExpr: string, fromDate?: Date): Date;
|
|
6
|
+
export declare function computeJitter(taskId: string, recurring: boolean, scheduleMinutes: number): number;
|