@jl1990/pi-scheduler 0.1.1 → 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.
@@ -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
- `Scheduled for: ${task.dueAt}`,
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
- return `Scheduled ${task.action} task ${task.id} for ${new Date(task.dueAt).toLocaleString()}: ${core.taskSummary(task)}`;
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>): string {
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
- task.followUpPrompt,
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 timers = new Map<string, NodeJS.Timeout>();
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: 1, updatedAt: new Date().toISOString(), tasks }, null, 2) + "\n";
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 timer of timers.values()) clearTimeout(timer);
120
- timers = new Map();
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(tasks).filter((task: ScheduledTask) => taskBelongsToSession(task, ctx)).length;
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 scheduleTaskTimer(task: ScheduledTask, ctx: ExtensionContext): void {
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
- const dueAt = Date.parse(task.dueAt);
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
- timers.delete(task.id);
214
+ handles.delete(task.id);
140
215
  if (Date.now() < dueAt) {
141
- scheduleTaskTimer(task, ctx);
216
+ scheduleTaskHandle(task, ctx);
142
217
  return;
143
218
  }
144
219
  void fireTask(task.id, ctx);
145
220
  }, timerDelay);
146
- timers.set(task.id, timer);
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.followUpPrompt) {
213
- sendAgentPrompt(pi, ctx, shellResultPrompt(task, shellResult));
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.markScheduledTaskFired(tasks, task.id, new Date(), result);
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
- whenText: input.whenText ?? input.when,
327
+ schedule: input.schedule ?? input.when ?? input.whenText,
251
328
  cwd: input.cwd ?? ctx.cwd,
252
- sessionFile: currentSessionFile(ctx),
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> = { action: parsed.action, whenText: parsed.whenText, cwd: ctx.cwd };
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) ctx.ui.setStatus("scheduler", undefined);
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] <when> :: <payload>", "warning");
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", whenText: parsed.whenText, message: parsed.payload, cwd: ctx.cwd },
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 fired/cancelled/failed tasks",
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 = tasks.filter((task) => taskBelongsToSession(task, ctx));
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 pending scheduled task by id or id prefix",
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 visible = tasks.filter((task) => taskBelongsToSession(task, ctx));
350
- const task = core.cancelScheduledTask(visible, id, new Date());
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(`Cancelled scheduled task ${task.id}`, "info");
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
- "Prefer schedule_task action='prompt' for agentic follow-ups; include enough context for the future agent to know what to check and what to do next.",
370
- "For polling workflows, schedule a future prompt that says to check the status and schedule another check if the work is still pending.",
371
- "Use schedule_task action='shell' with followUpPrompt when a fixed command should run later and its output should be reviewed by the agent.",
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
- when: Type.String({
379
- description: "When to run, e.g. '5m', 'in 10 minutes', 'tomorrow at 9am', '14:30', or an ISO datetime.",
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
- title: Type.Optional(Type.String({ description: "Optional human-readable title." })),
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 for the current Pi session.",
430
- promptSnippet: "List pending/all scheduled future actions in the current Pi session",
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 = tasks.filter((task) => taskBelongsToSession(task, ctx));
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 pending scheduled task by id or id prefix.",
447
- promptSnippet: "Cancel a pending scheduled task by id or prefix",
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
- const visible = tasks.filter((task) => taskBelongsToSession(task, ctx));
454
- const task = core.cancelScheduledTask(visible, params.id, new Date());
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: `Cancelled scheduled task ${task.id}` }],
699
+ content: [{ type: "text", text: `${params.action} scheduled task ${task.id}` }],
459
700
  details: { task, pending: core.pendingTasks(tasks) },
460
701
  };
461
702
  },