@jl1990/pi-scheduler 0.1.0 → 0.2.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/README.md +126 -36
- package/extensions/scheduler/index.ts +303 -62
- package/extensions/scheduler/scheduler-core.cjs +387 -59
- package/package.json +8 -5
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { Cron } from "croner";
|
|
4
5
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
7
|
import { dirname, join } from "node:path";
|
|
@@ -10,6 +11,11 @@ import { Type } from "typebox";
|
|
|
10
11
|
const core = require("./scheduler-core.cjs");
|
|
11
12
|
|
|
12
13
|
const ACTIONS = ["notify", "prompt", "shell", "message"] as const;
|
|
14
|
+
const TYPES = ["once", "interval", "cron"] as const;
|
|
15
|
+
const SCOPES = ["session", "cwd", "global"] as const;
|
|
16
|
+
const WAKE_ON = ["always", "failure", "success", "never"] as const;
|
|
17
|
+
const MANAGE_ACTIONS = ["enable", "disable", "remove", "update", "cleanup"] as const;
|
|
18
|
+
|
|
13
19
|
const STATE_FILE = join(homedir(), ".pi", "agent", "state", "scheduler", "tasks.json");
|
|
14
20
|
const MAX_TIMER_DELAY_MS = 2_147_483_647; // setTimeout's practical max (~24.8 days)
|
|
15
21
|
const DEFAULT_SHELL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
@@ -17,6 +23,9 @@ const MAX_STORED_OUTPUT_CHARS = 12_000;
|
|
|
17
23
|
const MAX_PROMPT_OUTPUT_CHARS = 18_000;
|
|
18
24
|
|
|
19
25
|
type ScheduledTask = Record<string, any>;
|
|
26
|
+
type TimerHandle =
|
|
27
|
+
| { kind: "timeout"; handle: NodeJS.Timeout }
|
|
28
|
+
| { kind: "cron"; handle: Cron };
|
|
20
29
|
|
|
21
30
|
function truncateMiddle(text: string | undefined, maxChars: number): string {
|
|
22
31
|
const value = text ?? "";
|
|
@@ -31,6 +40,10 @@ function currentSessionFile(ctx: ExtensionContext): string | undefined {
|
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
function taskBelongsToSession(task: ScheduledTask, ctx: ExtensionContext): boolean {
|
|
43
|
+
const scope = task.scope ?? "session";
|
|
44
|
+
if (scope === "global") return true;
|
|
45
|
+
if (scope === "cwd") return !task.cwd || task.cwd === ctx.cwd;
|
|
46
|
+
|
|
34
47
|
const sessionFile = currentSessionFile(ctx);
|
|
35
48
|
return !task.sessionFile || !sessionFile || task.sessionFile === sessionFile;
|
|
36
49
|
}
|
|
@@ -46,17 +59,22 @@ function sendAgentPrompt(pi: ExtensionAPI, ctx: ExtensionContext, prompt: string
|
|
|
46
59
|
function scheduledPromptHeader(task: ScheduledTask): string {
|
|
47
60
|
return [
|
|
48
61
|
`[Scheduled task ${task.id} fired]`,
|
|
62
|
+
`Name: ${task.name ?? task.title ?? "(unnamed)"}`,
|
|
49
63
|
`Action: ${task.action}`,
|
|
50
|
-
`
|
|
64
|
+
`Type: ${task.type}`,
|
|
65
|
+
`Schedule: ${task.schedule}`,
|
|
66
|
+
`Scheduled for: ${task.nextRun ?? task.dueAt ?? "unknown"}`,
|
|
51
67
|
"",
|
|
52
68
|
].join("\n");
|
|
53
69
|
}
|
|
54
70
|
|
|
55
71
|
function taskCreatedText(task: ScheduledTask): string {
|
|
56
|
-
|
|
72
|
+
const label = task.name ? ` "${task.name}"` : "";
|
|
73
|
+
const next = task.nextRun ? ` next run ${new Date(task.nextRun).toLocaleString()}` : "";
|
|
74
|
+
return `Scheduled ${task.action}/${task.type} task${label} ${task.id}${next}: ${core.taskSummary(task)}`;
|
|
57
75
|
}
|
|
58
76
|
|
|
59
|
-
function shellResultPrompt(task: ScheduledTask, result: Record<string, any
|
|
77
|
+
function shellResultPrompt(task: ScheduledTask, result: Record<string, any>, instruction: string): string {
|
|
60
78
|
const stdout = truncateMiddle(result.stdout ?? "", MAX_PROMPT_OUTPUT_CHARS);
|
|
61
79
|
const stderr = truncateMiddle(result.stderr ?? "", MAX_PROMPT_OUTPUT_CHARS);
|
|
62
80
|
return [
|
|
@@ -79,15 +97,20 @@ function shellResultPrompt(task: ScheduledTask, result: Record<string, any>): st
|
|
|
79
97
|
"```",
|
|
80
98
|
"",
|
|
81
99
|
"Follow-up instruction:",
|
|
82
|
-
|
|
100
|
+
instruction,
|
|
83
101
|
].join("\n");
|
|
84
102
|
}
|
|
85
103
|
|
|
104
|
+
function taskLabel(task: ScheduledTask): string {
|
|
105
|
+
return task.name || task.title || task.id;
|
|
106
|
+
}
|
|
107
|
+
|
|
86
108
|
export default function schedulerExtension(pi: ExtensionAPI) {
|
|
87
109
|
let tasks: ScheduledTask[] = [];
|
|
88
|
-
let
|
|
110
|
+
let handles = new Map<string, TimerHandle>();
|
|
89
111
|
let activeCtx: ExtensionContext | undefined;
|
|
90
112
|
let saveQueue: Promise<void> = Promise.resolve();
|
|
113
|
+
let widgetEnabled = true;
|
|
91
114
|
const firing = new Set<string>();
|
|
92
115
|
|
|
93
116
|
async function loadTasks(): Promise<void> {
|
|
@@ -105,7 +128,7 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
105
128
|
}
|
|
106
129
|
|
|
107
130
|
async function saveTasks(): Promise<void> {
|
|
108
|
-
const payload = JSON.stringify({ version:
|
|
131
|
+
const payload = JSON.stringify({ version: 2, updatedAt: new Date().toISOString(), tasks }, null, 2) + "\n";
|
|
109
132
|
saveQueue = saveQueue.then(async () => {
|
|
110
133
|
await mkdir(dirname(STATE_FILE), { recursive: true });
|
|
111
134
|
const tmp = `${STATE_FILE}.${process.pid}.tmp`;
|
|
@@ -115,43 +138,93 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
115
138
|
return saveQueue;
|
|
116
139
|
}
|
|
117
140
|
|
|
141
|
+
function clearHandle(id: string): void {
|
|
142
|
+
const handle = handles.get(id);
|
|
143
|
+
if (!handle) return;
|
|
144
|
+
if (handle.kind === "cron") handle.handle.stop();
|
|
145
|
+
else clearTimeout(handle.handle);
|
|
146
|
+
handles.delete(id);
|
|
147
|
+
}
|
|
148
|
+
|
|
118
149
|
function clearTimers(): void {
|
|
119
|
-
for (const
|
|
120
|
-
|
|
150
|
+
for (const id of [...handles.keys()]) clearHandle(id);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function visibleTasks(ctx = activeCtx): ScheduledTask[] {
|
|
154
|
+
if (!ctx) return tasks;
|
|
155
|
+
return tasks.filter((task) => taskBelongsToSession(task, ctx));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function updateWidget(ctx = activeCtx): void {
|
|
159
|
+
if (!ctx?.hasUI) return;
|
|
160
|
+
if (!widgetEnabled) {
|
|
161
|
+
ctx.ui.setWidget("scheduler", undefined);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const upcoming = core.pendingTasks(visibleTasks(ctx)).slice(0, 3);
|
|
166
|
+
if (upcoming.length === 0) {
|
|
167
|
+
ctx.ui.setWidget("scheduler", undefined);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const lines = ["⏰ Scheduled Actions"];
|
|
172
|
+
for (const task of upcoming) {
|
|
173
|
+
const next = task.nextRun ? core.formatRelativeTime(task.nextRun) : "no next run";
|
|
174
|
+
const last = task.lastStatus ? ` last=${task.lastStatus}` : "";
|
|
175
|
+
lines.push(` ✓ ${taskLabel(task)} ${task.action}/${task.type} ${next} runs=${task.runCount ?? 0}${last}`);
|
|
176
|
+
}
|
|
177
|
+
ctx.ui.setWidget("scheduler", lines, { placement: "belowEditor" });
|
|
121
178
|
}
|
|
122
179
|
|
|
123
180
|
function updateStatus(ctx = activeCtx): void {
|
|
124
181
|
if (!ctx?.hasUI) return;
|
|
125
|
-
const count = core.pendingTasks(
|
|
182
|
+
const count = core.pendingTasks(visibleTasks(ctx)).length;
|
|
126
183
|
ctx.ui.setStatus("scheduler", count ? `⏰ ${count} scheduled` : undefined);
|
|
184
|
+
updateWidget(ctx);
|
|
127
185
|
}
|
|
128
186
|
|
|
129
|
-
function
|
|
130
|
-
if (task.status !== "pending") return;
|
|
187
|
+
function scheduleTaskHandle(task: ScheduledTask, ctx: ExtensionContext): void {
|
|
188
|
+
if (task.enabled === false || task.status !== "pending") return;
|
|
131
189
|
if (!taskBelongsToSession(task, ctx)) return;
|
|
190
|
+
clearHandle(task.id);
|
|
132
191
|
|
|
133
|
-
|
|
192
|
+
if (task.type === "cron") {
|
|
193
|
+
try {
|
|
194
|
+
const cron = new Cron(task.schedule, () => {
|
|
195
|
+
void fireTask(task.id, ctx);
|
|
196
|
+
});
|
|
197
|
+
handles.set(task.id, { kind: "cron", handle: cron });
|
|
198
|
+
} catch (error: any) {
|
|
199
|
+
task.enabled = false;
|
|
200
|
+
task.status = "failed";
|
|
201
|
+
task.lastStatus = "error";
|
|
202
|
+
task.lastError = error?.message ?? String(error);
|
|
203
|
+
void saveTasks();
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const dueAt = Date.parse(task.nextRun ?? task.dueAt);
|
|
134
209
|
if (!Number.isFinite(dueAt)) return;
|
|
135
210
|
const delay = Math.max(0, dueAt - Date.now());
|
|
136
211
|
const timerDelay = Math.min(delay, MAX_TIMER_DELAY_MS);
|
|
137
212
|
|
|
138
213
|
const timer = setTimeout(() => {
|
|
139
|
-
|
|
214
|
+
handles.delete(task.id);
|
|
140
215
|
if (Date.now() < dueAt) {
|
|
141
|
-
|
|
216
|
+
scheduleTaskHandle(task, ctx);
|
|
142
217
|
return;
|
|
143
218
|
}
|
|
144
219
|
void fireTask(task.id, ctx);
|
|
145
220
|
}, timerDelay);
|
|
146
|
-
|
|
221
|
+
handles.set(task.id, { kind: "timeout", handle: timer });
|
|
147
222
|
}
|
|
148
223
|
|
|
149
224
|
function rescheduleAll(ctx = activeCtx): void {
|
|
150
225
|
if (!ctx) return;
|
|
151
226
|
clearTimers();
|
|
152
|
-
for (const task of core.pendingTasks(tasks))
|
|
153
|
-
scheduleTaskTimer(task, ctx);
|
|
154
|
-
}
|
|
227
|
+
for (const task of core.pendingTasks(tasks)) scheduleTaskHandle(task, ctx);
|
|
155
228
|
updateStatus(ctx);
|
|
156
229
|
}
|
|
157
230
|
|
|
@@ -172,19 +245,19 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
172
245
|
const message = task.message ?? "Scheduled reminder";
|
|
173
246
|
if (ctx.hasUI) ctx.ui.notify(message, "info");
|
|
174
247
|
recordMessage(`🔔 ${message}`, { task }, false);
|
|
175
|
-
return { delivered: "notify" };
|
|
248
|
+
return { ok: true, delivered: "notify" };
|
|
176
249
|
}
|
|
177
250
|
|
|
178
251
|
if (task.action === "prompt") {
|
|
179
252
|
const prompt = `${scheduledPromptHeader(task)}${task.prompt}`;
|
|
180
253
|
sendAgentPrompt(pi, ctx, prompt);
|
|
181
|
-
return { delivered: "prompt" };
|
|
254
|
+
return { ok: true, delivered: "prompt" };
|
|
182
255
|
}
|
|
183
256
|
|
|
184
257
|
if (task.action === "message") {
|
|
185
258
|
const message = task.message ?? "Scheduled message";
|
|
186
259
|
recordMessage(`⏰ ${message}`, { task }, task.triggerTurn !== false);
|
|
187
|
-
return { delivered: "message", triggerTurn: task.triggerTurn !== false };
|
|
260
|
+
return { ok: true, delivered: "message", triggerTurn: task.triggerTurn !== false };
|
|
188
261
|
}
|
|
189
262
|
|
|
190
263
|
if (task.action === "shell") {
|
|
@@ -194,6 +267,7 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
194
267
|
|
|
195
268
|
const result = await pi.exec("bash", ["-lc", task.command], { cwd, timeout });
|
|
196
269
|
const shellResult = {
|
|
270
|
+
ok: result.code === 0 && result.killed !== true,
|
|
197
271
|
command: task.command,
|
|
198
272
|
cwd,
|
|
199
273
|
timeoutMs: timeout,
|
|
@@ -209,8 +283,9 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
209
283
|
false,
|
|
210
284
|
);
|
|
211
285
|
|
|
212
|
-
if (task
|
|
213
|
-
|
|
286
|
+
if (core.shouldWakeForShellResult(task, shellResult)) {
|
|
287
|
+
const instruction = core.selectShellFollowUpPrompt(task, shellResult);
|
|
288
|
+
if (instruction) sendAgentPrompt(pi, ctx, shellResultPrompt(task, shellResult, instruction));
|
|
214
289
|
}
|
|
215
290
|
|
|
216
291
|
return shellResult;
|
|
@@ -221,15 +296,16 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
221
296
|
|
|
222
297
|
async function fireTask(taskId: string, ctx: ExtensionContext): Promise<void> {
|
|
223
298
|
const task = tasks.find((candidate) => candidate.id === taskId);
|
|
224
|
-
if (!task || task.status !== "pending" || firing.has(task.id)) return;
|
|
299
|
+
if (!task || task.enabled === false || task.status !== "pending" || firing.has(task.id)) return;
|
|
225
300
|
if (!taskBelongsToSession(task, ctx)) return;
|
|
226
301
|
|
|
227
302
|
firing.add(task.id);
|
|
228
303
|
try {
|
|
229
304
|
core.markScheduledTaskRunning(tasks, task.id, new Date());
|
|
230
305
|
await saveTasks();
|
|
306
|
+
updateStatus(ctx);
|
|
231
307
|
const result = await executeTask(task, ctx);
|
|
232
|
-
core.
|
|
308
|
+
core.markScheduledTaskCompleted(tasks, task.id, new Date(), result, { ok: result.ok !== false });
|
|
233
309
|
await saveTasks();
|
|
234
310
|
} catch (error: any) {
|
|
235
311
|
core.markScheduledTaskFailed(tasks, task.id, new Date(), error);
|
|
@@ -244,12 +320,14 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
244
320
|
}
|
|
245
321
|
|
|
246
322
|
async function createAndSchedule(input: Record<string, any>, ctx: ExtensionContext): Promise<ScheduledTask> {
|
|
323
|
+
const scope = input.scope ?? "session";
|
|
247
324
|
const task = core.createScheduledTask(
|
|
248
325
|
{
|
|
249
326
|
...input,
|
|
250
|
-
|
|
327
|
+
schedule: input.schedule ?? input.when ?? input.whenText,
|
|
251
328
|
cwd: input.cwd ?? ctx.cwd,
|
|
252
|
-
|
|
329
|
+
scope,
|
|
330
|
+
sessionFile: scope === "session" ? currentSessionFile(ctx) : undefined,
|
|
253
331
|
},
|
|
254
332
|
new Date(),
|
|
255
333
|
);
|
|
@@ -261,12 +339,40 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
261
339
|
|
|
262
340
|
function parseCommandTask(args: string, ctx: ExtensionContext): Record<string, any> {
|
|
263
341
|
const parsed = core.splitScheduleCommand(args, new Date());
|
|
264
|
-
const base: Record<string, any> = {
|
|
342
|
+
const base: Record<string, any> = {
|
|
343
|
+
action: parsed.action,
|
|
344
|
+
type: parsed.type,
|
|
345
|
+
schedule: parsed.schedule,
|
|
346
|
+
cwd: ctx.cwd,
|
|
347
|
+
};
|
|
265
348
|
if (parsed.action === "prompt") return { ...base, prompt: parsed.payload };
|
|
266
349
|
if (parsed.action === "shell") return { ...base, command: parsed.payload };
|
|
267
350
|
return { ...base, message: parsed.payload };
|
|
268
351
|
}
|
|
269
352
|
|
|
353
|
+
function cleanupVisibleTasks(ctx: ExtensionContext): ScheduledTask[] {
|
|
354
|
+
const removable = visibleTasks(ctx).filter(
|
|
355
|
+
(task) => task.enabled === false || ["fired", "cancelled", "failed"].includes(task.status),
|
|
356
|
+
);
|
|
357
|
+
const removableIds = new Set(removable.map((task) => task.id));
|
|
358
|
+
tasks = tasks.filter((task) => !removableIds.has(task.id));
|
|
359
|
+
for (const task of removable) clearHandle(task.id);
|
|
360
|
+
return removable;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function mutateVisibleTask(
|
|
364
|
+
ctx: ExtensionContext,
|
|
365
|
+
id: string,
|
|
366
|
+
mutator: (visible: ScheduledTask[]) => ScheduledTask,
|
|
367
|
+
): Promise<ScheduledTask> {
|
|
368
|
+
await loadTasks();
|
|
369
|
+
const visible = visibleTasks(ctx);
|
|
370
|
+
const task = mutator(visible);
|
|
371
|
+
await saveTasks();
|
|
372
|
+
rescheduleAll(ctx);
|
|
373
|
+
return task;
|
|
374
|
+
}
|
|
375
|
+
|
|
270
376
|
pi.registerMessageRenderer("scheduled-task", (message, options, theme) => {
|
|
271
377
|
let text = `${theme.fg("accent", theme.bold("scheduled"))} ${message.content}`;
|
|
272
378
|
if (options.expanded && message.details) {
|
|
@@ -283,7 +389,10 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
283
389
|
|
|
284
390
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
285
391
|
clearTimers();
|
|
286
|
-
if (ctx.hasUI)
|
|
392
|
+
if (ctx.hasUI) {
|
|
393
|
+
ctx.ui.setStatus("scheduler", undefined);
|
|
394
|
+
ctx.ui.setWidget("scheduler", undefined);
|
|
395
|
+
}
|
|
287
396
|
activeCtx = undefined;
|
|
288
397
|
});
|
|
289
398
|
|
|
@@ -291,7 +400,7 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
291
400
|
description: "Schedule a notify, prompt, shell command, or message action",
|
|
292
401
|
handler: async (args, ctx) => {
|
|
293
402
|
if (!args.trim()) {
|
|
294
|
-
ctx.ui.notify("Usage: /schedule [notify|prompt|shell|message] <
|
|
403
|
+
ctx.ui.notify("Usage: /schedule [notify|prompt|shell|message] [once|every|interval|cron] <schedule> :: <payload>", "warning");
|
|
295
404
|
return;
|
|
296
405
|
}
|
|
297
406
|
try {
|
|
@@ -314,7 +423,7 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
314
423
|
try {
|
|
315
424
|
const parsed = core.splitScheduleCommand(`notify ${args}`, new Date());
|
|
316
425
|
const task = await createAndSchedule(
|
|
317
|
-
{ action: "notify",
|
|
426
|
+
{ action: "notify", type: parsed.type, schedule: parsed.schedule, message: parsed.payload, cwd: ctx.cwd },
|
|
318
427
|
ctx,
|
|
319
428
|
);
|
|
320
429
|
ctx.ui.notify(taskCreatedText(task), "info");
|
|
@@ -326,71 +435,148 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
326
435
|
});
|
|
327
436
|
|
|
328
437
|
pi.registerCommand("schedules", {
|
|
329
|
-
description: "List scheduled tasks; pass 'all' to include
|
|
438
|
+
description: "List scheduled tasks; pass 'all' to include disabled/completed/cancelled/failed tasks",
|
|
330
439
|
handler: async (args, ctx) => {
|
|
331
440
|
await loadTasks();
|
|
332
441
|
const includeAll = args.trim().toLowerCase() === "all";
|
|
333
|
-
const visible =
|
|
442
|
+
const visible = visibleTasks(ctx);
|
|
334
443
|
recordMessage(core.formatTaskList(visible, new Date(), { includeAll }), { includeAll, tasks: visible }, false);
|
|
335
444
|
updateStatus(ctx);
|
|
336
445
|
},
|
|
337
446
|
});
|
|
338
447
|
|
|
339
448
|
pi.registerCommand("schedule-cancel", {
|
|
340
|
-
description: "Cancel a
|
|
449
|
+
description: "Cancel a scheduled task by id or id prefix",
|
|
341
450
|
handler: async (args, ctx) => {
|
|
342
451
|
const id = args.trim();
|
|
343
452
|
if (!id) {
|
|
344
453
|
ctx.ui.notify("Usage: /schedule-cancel <id>", "warning");
|
|
345
454
|
return;
|
|
346
455
|
}
|
|
456
|
+
try {
|
|
457
|
+
const task = await mutateVisibleTask(ctx, id, (visible) => core.cancelScheduledTask(visible, id, new Date()));
|
|
458
|
+
ctx.ui.notify(`Cancelled scheduled task ${task.id}`, "info");
|
|
459
|
+
recordMessage(`Cancelled scheduled task ${task.id}`, { task }, false);
|
|
460
|
+
} catch (error: any) {
|
|
461
|
+
ctx.ui.notify(error?.message ?? String(error), "error");
|
|
462
|
+
}
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
pi.registerCommand("schedule-enable", {
|
|
467
|
+
description: "Enable a scheduled task by id or id prefix",
|
|
468
|
+
handler: async (args, ctx) => {
|
|
469
|
+
try {
|
|
470
|
+
const task = await mutateVisibleTask(ctx, args.trim(), (visible) => core.enableScheduledTask(visible, args.trim(), new Date()));
|
|
471
|
+
ctx.ui.notify(`Enabled scheduled task ${task.id}`, "info");
|
|
472
|
+
} catch (error: any) {
|
|
473
|
+
ctx.ui.notify(error?.message ?? String(error), "error");
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
pi.registerCommand("schedule-disable", {
|
|
479
|
+
description: "Disable a scheduled task by id or id prefix",
|
|
480
|
+
handler: async (args, ctx) => {
|
|
481
|
+
try {
|
|
482
|
+
const task = await mutateVisibleTask(ctx, args.trim(), (visible) => core.disableScheduledTask(visible, args.trim(), new Date()));
|
|
483
|
+
ctx.ui.notify(`Disabled scheduled task ${task.id}`, "info");
|
|
484
|
+
} catch (error: any) {
|
|
485
|
+
ctx.ui.notify(error?.message ?? String(error), "error");
|
|
486
|
+
}
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
pi.registerCommand("schedule-remove", {
|
|
491
|
+
description: "Remove a scheduled task by id or id prefix",
|
|
492
|
+
handler: async (args, ctx) => {
|
|
493
|
+
const id = args.trim();
|
|
347
494
|
try {
|
|
348
495
|
await loadTasks();
|
|
349
|
-
const
|
|
350
|
-
const
|
|
496
|
+
const visibleRemoved = core.removeScheduledTask(visibleTasks(ctx), id);
|
|
497
|
+
const removed = core.removeScheduledTask(tasks, visibleRemoved.id);
|
|
498
|
+
clearHandle(removed.id);
|
|
351
499
|
await saveTasks();
|
|
352
500
|
rescheduleAll(ctx);
|
|
353
|
-
ctx.ui.notify(`
|
|
354
|
-
recordMessage(`Cancelled scheduled task ${task.id}`, { task }, false);
|
|
501
|
+
ctx.ui.notify(`Removed scheduled task ${removed.id}`, "info");
|
|
355
502
|
} catch (error: any) {
|
|
356
503
|
ctx.ui.notify(error?.message ?? String(error), "error");
|
|
357
504
|
}
|
|
358
505
|
},
|
|
359
506
|
});
|
|
360
507
|
|
|
508
|
+
pi.registerCommand("schedule-cleanup", {
|
|
509
|
+
description: "Remove disabled/completed/cancelled/failed scheduled tasks visible to this session",
|
|
510
|
+
handler: async (_args, ctx) => {
|
|
511
|
+
await loadTasks();
|
|
512
|
+
const removed = cleanupVisibleTasks(ctx);
|
|
513
|
+
await saveTasks();
|
|
514
|
+
rescheduleAll(ctx);
|
|
515
|
+
ctx.ui.notify(`Cleaned up ${removed.length} scheduled task(s)`, "info");
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
pi.registerCommand("schedule-widget", {
|
|
520
|
+
description: "Turn the compact scheduled-actions widget on or off for this session",
|
|
521
|
+
handler: async (args, ctx) => {
|
|
522
|
+
const value = args.trim().toLowerCase();
|
|
523
|
+
if (value === "off" || value === "false" || value === "0") widgetEnabled = false;
|
|
524
|
+
else if (value === "on" || value === "true" || value === "1" || value === "") widgetEnabled = true;
|
|
525
|
+
else {
|
|
526
|
+
ctx.ui.notify("Usage: /schedule-widget [on|off]", "warning");
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
updateStatus(ctx);
|
|
530
|
+
ctx.ui.notify(`Schedule widget ${widgetEnabled ? "enabled" : "disabled"}`, "info");
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
|
|
361
534
|
pi.registerTool({
|
|
362
535
|
name: "schedule_task",
|
|
363
536
|
label: "Schedule Task",
|
|
364
537
|
description:
|
|
365
|
-
"Schedule a future action in this Pi session: notify the user, wake the agent with a prompt, run a shell command, or send a custom message.",
|
|
366
|
-
promptSnippet: "Schedule future notify, prompt, shell, or message actions in the current Pi session",
|
|
538
|
+
"Schedule a future or recurring action in this Pi session: notify the user, wake the agent with a prompt, run a shell command, or send a custom message.",
|
|
539
|
+
promptSnippet: "Schedule future/recurring notify, prompt, shell, or message actions in the current Pi session",
|
|
367
540
|
promptGuidelines: [
|
|
368
541
|
"Use schedule_task when the user asks to do something later, when waiting on external systems such as CI/CD pipelines, or when the agent needs to wake itself up to continue work.",
|
|
369
|
-
"
|
|
370
|
-
"
|
|
371
|
-
"
|
|
542
|
+
"Use schedule_task type='once' for one-shot work, type='interval' for repeated polling, and type='cron' for calendar-style schedules.",
|
|
543
|
+
"Prefer schedule_task action='shell' with followUpPrompt/failurePrompt when a fixed command should run later and its output should be reviewed by the agent.",
|
|
544
|
+
"For bounded polling workflows, set maxRuns so interval tasks do not run forever.",
|
|
372
545
|
],
|
|
373
546
|
parameters: Type.Object({
|
|
374
547
|
action: StringEnum(ACTIONS, {
|
|
375
548
|
description: "What to do at the scheduled time. Use prompt to wake the agent.",
|
|
376
549
|
default: "prompt",
|
|
377
550
|
}),
|
|
378
|
-
|
|
379
|
-
description: "
|
|
380
|
-
|
|
551
|
+
type: Type.Optional(
|
|
552
|
+
StringEnum(TYPES, { description: "Schedule type: once (default), interval, or cron.", default: "once" }),
|
|
553
|
+
),
|
|
554
|
+
when: Type.Optional(
|
|
555
|
+
Type.String({
|
|
556
|
+
description: "Backward-compatible alias for schedule, e.g. '5m', 'in 10 minutes', 'tomorrow at 9am'.",
|
|
557
|
+
}),
|
|
558
|
+
),
|
|
559
|
+
schedule: Type.Optional(
|
|
560
|
+
Type.String({ description: "Schedule string. once: '5m'/'tomorrow at 9am'; interval: '5m'; cron: '0 */5 * * * *'." }),
|
|
561
|
+
),
|
|
562
|
+
name: Type.Optional(Type.String({ description: "Optional human-readable task name." })),
|
|
563
|
+
description: Type.Optional(Type.String({ description: "Optional task description." })),
|
|
564
|
+
scope: Type.Optional(StringEnum(SCOPES, { description: "Task scope. Default session.", default: "session" })),
|
|
565
|
+
enabled: Type.Optional(Type.Boolean({ description: "Whether the task starts enabled. Default true." })),
|
|
566
|
+
maxRuns: Type.Optional(Type.Number({ description: "Disable after this many runs. Useful for bounded polling.", minimum: 1 })),
|
|
381
567
|
message: Type.Optional(Type.String({ description: "Message for notify/message actions." })),
|
|
382
568
|
prompt: Type.Optional(Type.String({ description: "User prompt to inject for prompt actions." })),
|
|
383
569
|
command: Type.Optional(Type.String({ description: "Shell command to run for shell actions." })),
|
|
384
570
|
payload: Type.Optional(Type.String({ description: "Generic payload fallback for any action." })),
|
|
385
571
|
cwd: Type.Optional(Type.String({ description: "Working directory for shell actions; defaults to current cwd." })),
|
|
386
572
|
timeoutMs: Type.Optional(Type.Number({ description: "Shell timeout in milliseconds.", minimum: 1000 })),
|
|
573
|
+
wakeOn: Type.Optional(StringEnum(WAKE_ON, { description: "For shell actions: when to wake the agent. Default always if a prompt is configured, otherwise never." })),
|
|
387
574
|
followUpPrompt: Type.Optional(
|
|
388
|
-
Type.String({
|
|
389
|
-
description:
|
|
390
|
-
"For shell actions: if set, wake the agent after the command completes and include stdout/stderr plus this instruction.",
|
|
391
|
-
}),
|
|
575
|
+
Type.String({ description: "For shell actions: generic follow-up instruction sent with stdout/stderr." }),
|
|
392
576
|
),
|
|
393
|
-
|
|
577
|
+
successPrompt: Type.Optional(Type.String({ description: "For shell actions: follow-up instruction used on exit code 0." })),
|
|
578
|
+
failurePrompt: Type.Optional(Type.String({ description: "For shell actions: follow-up instruction used on non-zero/timeout." })),
|
|
579
|
+
title: Type.Optional(Type.String({ description: "Backward-compatible human-readable title alias." })),
|
|
394
580
|
triggerTurn: Type.Optional(
|
|
395
581
|
Type.Boolean({ description: "For message actions: whether the message should trigger an agent turn. Default true." }),
|
|
396
582
|
),
|
|
@@ -398,7 +584,7 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
398
584
|
prepareArguments(args) {
|
|
399
585
|
if (!args || typeof args !== "object") return args;
|
|
400
586
|
const input = args as Record<string, any>;
|
|
401
|
-
if (input.when === undefined && typeof input.whenText === "string") {
|
|
587
|
+
if (input.schedule === undefined && input.when === undefined && typeof input.whenText === "string") {
|
|
402
588
|
return { ...input, when: input.whenText };
|
|
403
589
|
}
|
|
404
590
|
return args;
|
|
@@ -412,7 +598,7 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
412
598
|
},
|
|
413
599
|
renderCall(args, theme) {
|
|
414
600
|
return new Text(
|
|
415
|
-
`${theme.fg("toolTitle", theme.bold("schedule_task"))} ${theme.fg("muted", args.action ?? "prompt")} ${theme.fg("accent", args.when ?? "")}`,
|
|
601
|
+
`${theme.fg("toolTitle", theme.bold("schedule_task"))} ${theme.fg("muted", args.action ?? "prompt")}/${theme.fg("muted", args.type ?? "once")} ${theme.fg("accent", args.schedule ?? args.when ?? "")}`,
|
|
416
602
|
0,
|
|
417
603
|
0,
|
|
418
604
|
);
|
|
@@ -426,14 +612,14 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
426
612
|
pi.registerTool({
|
|
427
613
|
name: "list_scheduled_tasks",
|
|
428
614
|
label: "List Scheduled Tasks",
|
|
429
|
-
description: "List pending or all scheduled tasks
|
|
430
|
-
promptSnippet: "List pending/all scheduled future actions
|
|
615
|
+
description: "List pending or all scheduled tasks visible to the current Pi session.",
|
|
616
|
+
promptSnippet: "List pending/all scheduled future or recurring actions visible to the current Pi session",
|
|
431
617
|
parameters: Type.Object({
|
|
432
|
-
includeAll: Type.Optional(Type.Boolean({ description: "Include fired, cancelled, and failed tasks. Default false." })),
|
|
618
|
+
includeAll: Type.Optional(Type.Boolean({ description: "Include disabled, fired, cancelled, and failed tasks. Default false." })),
|
|
433
619
|
}),
|
|
434
620
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
435
621
|
await loadTasks();
|
|
436
|
-
const visible =
|
|
622
|
+
const visible = visibleTasks(ctx);
|
|
437
623
|
const text = core.formatTaskList(visible, new Date(), { includeAll: Boolean(params.includeAll) });
|
|
438
624
|
updateStatus(ctx);
|
|
439
625
|
return { content: [{ type: "text", text }], details: { tasks: visible } };
|
|
@@ -443,19 +629,74 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
443
629
|
pi.registerTool({
|
|
444
630
|
name: "cancel_scheduled_task",
|
|
445
631
|
label: "Cancel Scheduled Task",
|
|
446
|
-
description: "Cancel a
|
|
447
|
-
promptSnippet: "Cancel a
|
|
632
|
+
description: "Cancel a scheduled task by id or id prefix. Alias for disabling with cancelled status.",
|
|
633
|
+
promptSnippet: "Cancel a scheduled task by id or prefix",
|
|
448
634
|
parameters: Type.Object({
|
|
449
635
|
id: Type.String({ description: "Task id or unique id prefix." }),
|
|
450
636
|
}),
|
|
637
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
638
|
+
const task = await mutateVisibleTask(ctx, params.id, (visible) => core.cancelScheduledTask(visible, params.id, new Date()));
|
|
639
|
+
return {
|
|
640
|
+
content: [{ type: "text", text: `Cancelled scheduled task ${task.id}` }],
|
|
641
|
+
details: { task, pending: core.pendingTasks(tasks) },
|
|
642
|
+
};
|
|
643
|
+
},
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
pi.registerTool({
|
|
647
|
+
name: "manage_scheduled_task",
|
|
648
|
+
label: "Manage Scheduled Task",
|
|
649
|
+
description: "Enable, disable, remove, update, or cleanup scheduled tasks visible to this Pi session.",
|
|
650
|
+
promptSnippet: "Manage scheduled tasks: enable, disable, remove, update, or cleanup",
|
|
651
|
+
parameters: Type.Object({
|
|
652
|
+
action: StringEnum(MANAGE_ACTIONS, { description: "Management action to perform." }),
|
|
653
|
+
id: Type.Optional(Type.String({ description: "Task id or unique id prefix. Required except for cleanup." })),
|
|
654
|
+
name: Type.Optional(Type.String()),
|
|
655
|
+
description: Type.Optional(Type.String()),
|
|
656
|
+
type: Type.Optional(StringEnum(TYPES)),
|
|
657
|
+
schedule: Type.Optional(Type.String()),
|
|
658
|
+
scope: Type.Optional(StringEnum(SCOPES)),
|
|
659
|
+
enabled: Type.Optional(Type.Boolean()),
|
|
660
|
+
maxRuns: Type.Optional(Type.Number({ minimum: 1 })),
|
|
661
|
+
prompt: Type.Optional(Type.String()),
|
|
662
|
+
message: Type.Optional(Type.String()),
|
|
663
|
+
command: Type.Optional(Type.String()),
|
|
664
|
+
timeoutMs: Type.Optional(Type.Number({ minimum: 1000 })),
|
|
665
|
+
wakeOn: Type.Optional(StringEnum(WAKE_ON)),
|
|
666
|
+
followUpPrompt: Type.Optional(Type.String()),
|
|
667
|
+
successPrompt: Type.Optional(Type.String()),
|
|
668
|
+
failurePrompt: Type.Optional(Type.String()),
|
|
669
|
+
}),
|
|
451
670
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
452
671
|
await loadTasks();
|
|
453
|
-
|
|
454
|
-
|
|
672
|
+
if (params.action === "cleanup") {
|
|
673
|
+
const removed = cleanupVisibleTasks(ctx);
|
|
674
|
+
await saveTasks();
|
|
675
|
+
rescheduleAll(ctx);
|
|
676
|
+
return { content: [{ type: "text", text: `Cleaned up ${removed.length} scheduled task(s).` }], details: { removed } };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (!params.id) throw new Error("id is required for this management action");
|
|
680
|
+
let task: ScheduledTask;
|
|
681
|
+
if (params.action === "enable") {
|
|
682
|
+
task = core.enableScheduledTask(visibleTasks(ctx), params.id, new Date());
|
|
683
|
+
} else if (params.action === "disable") {
|
|
684
|
+
task = core.disableScheduledTask(visibleTasks(ctx), params.id, new Date());
|
|
685
|
+
} else if (params.action === "remove") {
|
|
686
|
+
const visibleRemoved = core.removeScheduledTask(visibleTasks(ctx), params.id);
|
|
687
|
+
task = core.removeScheduledTask(tasks, visibleRemoved.id);
|
|
688
|
+
clearHandle(task.id);
|
|
689
|
+
} else {
|
|
690
|
+
const updates = { ...params };
|
|
691
|
+
delete updates.action;
|
|
692
|
+
delete updates.id;
|
|
693
|
+
task = core.updateScheduledTask(visibleTasks(ctx), params.id, updates, new Date());
|
|
694
|
+
}
|
|
695
|
+
|
|
455
696
|
await saveTasks();
|
|
456
697
|
rescheduleAll(ctx);
|
|
457
698
|
return {
|
|
458
|
-
content: [{ type: "text", text:
|
|
699
|
+
content: [{ type: "text", text: `${params.action} scheduled task ${task.id}` }],
|
|
459
700
|
details: { task, pending: core.pendingTasks(tasks) },
|
|
460
701
|
};
|
|
461
702
|
},
|