@jl1990/pi-scheduler 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/LICENSE.md +21 -0
- package/README.md +189 -0
- package/extensions/scheduler/index.ts +463 -0
- package/extensions/scheduler/scheduler-core.cjs +388 -0
- package/package.json +65 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jl1990
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# Pi Scheduler
|
|
2
|
+
|
|
3
|
+
**Give Pi coding agents a clock: schedule reminders, shell commands, and self-waking prompts for CI polling and autonomous follow-ups.**
|
|
4
|
+
|
|
5
|
+
Pi Scheduler is a [Pi](https://github.com/earendil-works/pi) extension that lets an agent schedule future work from inside the conversation. Use it for simple reminders, delayed shell commands, or autonomous workflows where the agent needs to wake itself up later, check an external system, and continue.
|
|
6
|
+
|
|
7
|
+
## Why?
|
|
8
|
+
|
|
9
|
+
Coding agents often need to wait:
|
|
10
|
+
|
|
11
|
+
- A GitLab/GitHub pipeline is still running.
|
|
12
|
+
- A deployment needs a few minutes to roll out.
|
|
13
|
+
- A long build or test command should be checked later.
|
|
14
|
+
- You want the agent to remind you or continue a task after a delay.
|
|
15
|
+
|
|
16
|
+
Without scheduling, the agent has to stop and hope you come back. With Pi Scheduler, it can schedule its own follow-up prompt:
|
|
17
|
+
|
|
18
|
+
> “Check the pipeline again in 3 minutes. If it failed, inspect logs and fix it. If it is still running, schedule another check.”
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
- **Self-waking prompts** — schedule a future prompt that wakes the agent in the current Pi session.
|
|
23
|
+
- **Delayed shell commands** — run commands later with optional follow-up prompts containing stdout/stderr.
|
|
24
|
+
- **Reminders and messages** — notify the user or inject scheduled messages.
|
|
25
|
+
- **Agent-callable tools** — the LLM can schedule, list, and cancel tasks itself.
|
|
26
|
+
- **Slash commands** — manually schedule tasks from the Pi prompt.
|
|
27
|
+
- **Persistent state** — scheduled tasks are stored in `~/.pi/agent/state/scheduler/tasks.json`.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
Install from npm:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pi install npm:@jl1990/pi-scheduler
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or install directly from GitHub:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pi install git:git@github.com:jl1990/pi-scheduler.git
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Then restart Pi, or run:
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
/reload
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Agent tools
|
|
50
|
+
|
|
51
|
+
Pi Scheduler registers these tools for the agent:
|
|
52
|
+
|
|
53
|
+
- `schedule_task` — schedule a future action.
|
|
54
|
+
- `list_scheduled_tasks` — list pending or historical tasks.
|
|
55
|
+
- `cancel_scheduled_task` — cancel a pending task by ID or prefix.
|
|
56
|
+
|
|
57
|
+
### Scheduled action types
|
|
58
|
+
|
|
59
|
+
| Action | What it does | Best for |
|
|
60
|
+
| --- | --- | --- |
|
|
61
|
+
| `prompt` | Injects a future user prompt and wakes the agent | CI polling, deployments, autonomous follow-ups |
|
|
62
|
+
| `shell` | Runs a future shell command | Delayed checks, tests, status commands |
|
|
63
|
+
| `notify` | Shows a reminder/notification | Human reminders |
|
|
64
|
+
| `message` | Injects a scheduled custom message | Lightweight status/context messages |
|
|
65
|
+
|
|
66
|
+
## Example: autonomous GitLab pipeline polling
|
|
67
|
+
|
|
68
|
+
Ask the agent to create a pipeline, then schedule itself to check it:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"action": "prompt",
|
|
73
|
+
"when": "3m",
|
|
74
|
+
"prompt": "Check GitLab pipeline 123 for project jl1990/example. If it passed, report success. If it failed, inspect the failed job logs and propose or apply fixes. If it is still running, schedule another check in 3 minutes."
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
This pattern lets the agent keep working without you manually nudging it every few minutes.
|
|
79
|
+
|
|
80
|
+
## Example: run a command later, then wake the agent
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"action": "shell",
|
|
85
|
+
"when": "2m",
|
|
86
|
+
"command": "glab pipeline view 123 --repo jl1990/example",
|
|
87
|
+
"followUpPrompt": "Review this pipeline status. If it is still running, schedule another check. If it failed, inspect logs and fix the issue."
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
When the command finishes, Pi Scheduler sends the command output back to the agent together with your follow-up instruction.
|
|
92
|
+
|
|
93
|
+
## Slash commands
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
/schedule [notify|prompt|shell|message] <when> :: <payload>
|
|
97
|
+
/remind <when> <message>
|
|
98
|
+
/schedules
|
|
99
|
+
/schedules all
|
|
100
|
+
/schedule-cancel <id-or-prefix>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Examples:
|
|
104
|
+
|
|
105
|
+
```text
|
|
106
|
+
/remind 5m stretch
|
|
107
|
+
/schedule prompt 3m :: Check the GitLab pipeline and schedule another check if still running.
|
|
108
|
+
/schedule shell at 14:30 :: npm test
|
|
109
|
+
/schedules
|
|
110
|
+
/schedule-cancel task_abc123
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Time formats
|
|
114
|
+
|
|
115
|
+
Supported examples:
|
|
116
|
+
|
|
117
|
+
```text
|
|
118
|
+
5m
|
|
119
|
+
in 10 minutes
|
|
120
|
+
1h30m
|
|
121
|
+
2 days
|
|
122
|
+
tomorrow at 9am
|
|
123
|
+
14:30
|
|
124
|
+
2026-07-06T10:00:00
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Important limitations
|
|
128
|
+
|
|
129
|
+
Pi Scheduler currently uses **in-process timers**:
|
|
130
|
+
|
|
131
|
+
- If Pi is running, tasks fire at the scheduled time.
|
|
132
|
+
- If Pi is closed, tasks do not fire while Pi is closed.
|
|
133
|
+
- Pending/missed tasks are loaded again when the relevant Pi session starts, and due tasks fire then.
|
|
134
|
+
|
|
135
|
+
This is enough for live agent workflows like CI polling while a Pi session is open. A future version could add OS-level `cron`, `at`, launchd, systemd, or a small daemon for exact wakeups while Pi is not running.
|
|
136
|
+
|
|
137
|
+
## Development
|
|
138
|
+
|
|
139
|
+
Run tests:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
npm test
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Check what will be published to npm:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
npm pack --dry-run
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Load-check the extension locally:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
PI_OFFLINE=1 pi --no-extensions -e ./extensions/scheduler/index.ts --list-models __unlikely_model_filter__
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Try a command without starting a model turn:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
PI_OFFLINE=1 pi --no-extensions -e ./extensions/scheduler/index.ts --no-session --mode json -p "/schedules"
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Publishing
|
|
164
|
+
|
|
165
|
+
This package is published as:
|
|
166
|
+
|
|
167
|
+
```text
|
|
168
|
+
@jl1990/pi-scheduler
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The GitHub Actions workflow `.github/workflows/publish-npm.yml` publishes to npm when a GitHub Release is published. It also supports manual runs from the Actions tab, including a dry-run option.
|
|
172
|
+
|
|
173
|
+
Before the first automated publish, configure one of these npm auth methods:
|
|
174
|
+
|
|
175
|
+
1. **Trusted publishing** on npm, for repository `jl1990/pi-scheduler` and workflow `publish-npm.yml`.
|
|
176
|
+
2. Or a GitHub repository secret named `NPM_TOKEN` with publish permission.
|
|
177
|
+
|
|
178
|
+
Release flow:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
npm version patch # or minor/major
|
|
182
|
+
git push --follow-tags
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Then create/publish a GitHub Release for the new tag. The workflow will run tests, check package contents, and publish with npm provenance.
|
|
186
|
+
|
|
187
|
+
## Security notes
|
|
188
|
+
|
|
189
|
+
This extension can run scheduled shell commands with your local user permissions. Only install Pi packages from sources you trust, and review scheduled shell tasks before using them in sensitive environments.
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { Type } from "typebox";
|
|
8
|
+
|
|
9
|
+
// Keep the scheduler logic testable from plain node --test.
|
|
10
|
+
const core = require("./scheduler-core.cjs");
|
|
11
|
+
|
|
12
|
+
const ACTIONS = ["notify", "prompt", "shell", "message"] as const;
|
|
13
|
+
const STATE_FILE = join(homedir(), ".pi", "agent", "state", "scheduler", "tasks.json");
|
|
14
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647; // setTimeout's practical max (~24.8 days)
|
|
15
|
+
const DEFAULT_SHELL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
16
|
+
const MAX_STORED_OUTPUT_CHARS = 12_000;
|
|
17
|
+
const MAX_PROMPT_OUTPUT_CHARS = 18_000;
|
|
18
|
+
|
|
19
|
+
type ScheduledTask = Record<string, any>;
|
|
20
|
+
|
|
21
|
+
function truncateMiddle(text: string | undefined, maxChars: number): string {
|
|
22
|
+
const value = text ?? "";
|
|
23
|
+
if (value.length <= maxChars) return value;
|
|
24
|
+
const head = Math.floor(maxChars * 0.35);
|
|
25
|
+
const tail = maxChars - head - 80;
|
|
26
|
+
return `${value.slice(0, head)}\n\n[... truncated ${value.length - maxChars} characters ...]\n\n${value.slice(-tail)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function currentSessionFile(ctx: ExtensionContext): string | undefined {
|
|
30
|
+
return ctx.sessionManager.getSessionFile() ?? undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function taskBelongsToSession(task: ScheduledTask, ctx: ExtensionContext): boolean {
|
|
34
|
+
const sessionFile = currentSessionFile(ctx);
|
|
35
|
+
return !task.sessionFile || !sessionFile || task.sessionFile === sessionFile;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sendAgentPrompt(pi: ExtensionAPI, ctx: ExtensionContext, prompt: string): void {
|
|
39
|
+
if (ctx.isIdle()) {
|
|
40
|
+
pi.sendUserMessage(prompt);
|
|
41
|
+
} else {
|
|
42
|
+
pi.sendUserMessage(prompt, { deliverAs: "followUp" });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function scheduledPromptHeader(task: ScheduledTask): string {
|
|
47
|
+
return [
|
|
48
|
+
`[Scheduled task ${task.id} fired]`,
|
|
49
|
+
`Action: ${task.action}`,
|
|
50
|
+
`Scheduled for: ${task.dueAt}`,
|
|
51
|
+
"",
|
|
52
|
+
].join("\n");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function taskCreatedText(task: ScheduledTask): string {
|
|
56
|
+
return `Scheduled ${task.action} task ${task.id} for ${new Date(task.dueAt).toLocaleString()}: ${core.taskSummary(task)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function shellResultPrompt(task: ScheduledTask, result: Record<string, any>): string {
|
|
60
|
+
const stdout = truncateMiddle(result.stdout ?? "", MAX_PROMPT_OUTPUT_CHARS);
|
|
61
|
+
const stderr = truncateMiddle(result.stderr ?? "", MAX_PROMPT_OUTPUT_CHARS);
|
|
62
|
+
return [
|
|
63
|
+
scheduledPromptHeader(task).trimEnd(),
|
|
64
|
+
"A scheduled shell command completed.",
|
|
65
|
+
"",
|
|
66
|
+
`Command: ${task.command}`,
|
|
67
|
+
`CWD: ${result.cwd}`,
|
|
68
|
+
`Exit code: ${result.code}`,
|
|
69
|
+
`Timed out/killed: ${Boolean(result.killed)}`,
|
|
70
|
+
"",
|
|
71
|
+
"STDOUT:",
|
|
72
|
+
"```",
|
|
73
|
+
stdout,
|
|
74
|
+
"```",
|
|
75
|
+
"",
|
|
76
|
+
"STDERR:",
|
|
77
|
+
"```",
|
|
78
|
+
stderr,
|
|
79
|
+
"```",
|
|
80
|
+
"",
|
|
81
|
+
"Follow-up instruction:",
|
|
82
|
+
task.followUpPrompt,
|
|
83
|
+
].join("\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export default function schedulerExtension(pi: ExtensionAPI) {
|
|
87
|
+
let tasks: ScheduledTask[] = [];
|
|
88
|
+
let timers = new Map<string, NodeJS.Timeout>();
|
|
89
|
+
let activeCtx: ExtensionContext | undefined;
|
|
90
|
+
let saveQueue: Promise<void> = Promise.resolve();
|
|
91
|
+
const firing = new Set<string>();
|
|
92
|
+
|
|
93
|
+
async function loadTasks(): Promise<void> {
|
|
94
|
+
try {
|
|
95
|
+
const raw = await readFile(STATE_FILE, "utf8");
|
|
96
|
+
const parsed = JSON.parse(raw);
|
|
97
|
+
tasks = core.sanitizeTasks(parsed.tasks ?? parsed);
|
|
98
|
+
} catch (error: any) {
|
|
99
|
+
if (error?.code === "ENOENT") {
|
|
100
|
+
tasks = [];
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function saveTasks(): Promise<void> {
|
|
108
|
+
const payload = JSON.stringify({ version: 1, updatedAt: new Date().toISOString(), tasks }, null, 2) + "\n";
|
|
109
|
+
saveQueue = saveQueue.then(async () => {
|
|
110
|
+
await mkdir(dirname(STATE_FILE), { recursive: true });
|
|
111
|
+
const tmp = `${STATE_FILE}.${process.pid}.tmp`;
|
|
112
|
+
await writeFile(tmp, payload, "utf8");
|
|
113
|
+
await rename(tmp, STATE_FILE);
|
|
114
|
+
});
|
|
115
|
+
return saveQueue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function clearTimers(): void {
|
|
119
|
+
for (const timer of timers.values()) clearTimeout(timer);
|
|
120
|
+
timers = new Map();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function updateStatus(ctx = activeCtx): void {
|
|
124
|
+
if (!ctx?.hasUI) return;
|
|
125
|
+
const count = core.pendingTasks(tasks).filter((task: ScheduledTask) => taskBelongsToSession(task, ctx)).length;
|
|
126
|
+
ctx.ui.setStatus("scheduler", count ? `⏰ ${count} scheduled` : undefined);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function scheduleTaskTimer(task: ScheduledTask, ctx: ExtensionContext): void {
|
|
130
|
+
if (task.status !== "pending") return;
|
|
131
|
+
if (!taskBelongsToSession(task, ctx)) return;
|
|
132
|
+
|
|
133
|
+
const dueAt = Date.parse(task.dueAt);
|
|
134
|
+
if (!Number.isFinite(dueAt)) return;
|
|
135
|
+
const delay = Math.max(0, dueAt - Date.now());
|
|
136
|
+
const timerDelay = Math.min(delay, MAX_TIMER_DELAY_MS);
|
|
137
|
+
|
|
138
|
+
const timer = setTimeout(() => {
|
|
139
|
+
timers.delete(task.id);
|
|
140
|
+
if (Date.now() < dueAt) {
|
|
141
|
+
scheduleTaskTimer(task, ctx);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
void fireTask(task.id, ctx);
|
|
145
|
+
}, timerDelay);
|
|
146
|
+
timers.set(task.id, timer);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function rescheduleAll(ctx = activeCtx): void {
|
|
150
|
+
if (!ctx) return;
|
|
151
|
+
clearTimers();
|
|
152
|
+
for (const task of core.pendingTasks(tasks)) {
|
|
153
|
+
scheduleTaskTimer(task, ctx);
|
|
154
|
+
}
|
|
155
|
+
updateStatus(ctx);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function recordMessage(content: string, details?: Record<string, any>, triggerTurn = false): void {
|
|
159
|
+
pi.sendMessage(
|
|
160
|
+
{
|
|
161
|
+
customType: "scheduled-task",
|
|
162
|
+
content,
|
|
163
|
+
display: true,
|
|
164
|
+
details,
|
|
165
|
+
},
|
|
166
|
+
{ triggerTurn },
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function executeTask(task: ScheduledTask, ctx: ExtensionContext): Promise<Record<string, any>> {
|
|
171
|
+
if (task.action === "notify") {
|
|
172
|
+
const message = task.message ?? "Scheduled reminder";
|
|
173
|
+
if (ctx.hasUI) ctx.ui.notify(message, "info");
|
|
174
|
+
recordMessage(`🔔 ${message}`, { task }, false);
|
|
175
|
+
return { delivered: "notify" };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (task.action === "prompt") {
|
|
179
|
+
const prompt = `${scheduledPromptHeader(task)}${task.prompt}`;
|
|
180
|
+
sendAgentPrompt(pi, ctx, prompt);
|
|
181
|
+
return { delivered: "prompt" };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (task.action === "message") {
|
|
185
|
+
const message = task.message ?? "Scheduled message";
|
|
186
|
+
recordMessage(`⏰ ${message}`, { task }, task.triggerTurn !== false);
|
|
187
|
+
return { delivered: "message", triggerTurn: task.triggerTurn !== false };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (task.action === "shell") {
|
|
191
|
+
const cwd = task.cwd || ctx.cwd;
|
|
192
|
+
const timeout = task.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS;
|
|
193
|
+
if (ctx.hasUI) ctx.ui.notify(`Running scheduled command: ${task.command}`, "info");
|
|
194
|
+
|
|
195
|
+
const result = await pi.exec("bash", ["-lc", task.command], { cwd, timeout });
|
|
196
|
+
const shellResult = {
|
|
197
|
+
command: task.command,
|
|
198
|
+
cwd,
|
|
199
|
+
timeoutMs: timeout,
|
|
200
|
+
code: result.code,
|
|
201
|
+
killed: result.killed,
|
|
202
|
+
stdout: truncateMiddle(result.stdout ?? "", MAX_STORED_OUTPUT_CHARS),
|
|
203
|
+
stderr: truncateMiddle(result.stderr ?? "", MAX_STORED_OUTPUT_CHARS),
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
recordMessage(
|
|
207
|
+
`🖥️ Scheduled command ${task.id} finished with exit code ${result.code}: ${task.command}`,
|
|
208
|
+
{ task, result: shellResult },
|
|
209
|
+
false,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
if (task.followUpPrompt) {
|
|
213
|
+
sendAgentPrompt(pi, ctx, shellResultPrompt(task, shellResult));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return shellResult;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
throw new Error(`Unsupported scheduled action: ${task.action}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function fireTask(taskId: string, ctx: ExtensionContext): Promise<void> {
|
|
223
|
+
const task = tasks.find((candidate) => candidate.id === taskId);
|
|
224
|
+
if (!task || task.status !== "pending" || firing.has(task.id)) return;
|
|
225
|
+
if (!taskBelongsToSession(task, ctx)) return;
|
|
226
|
+
|
|
227
|
+
firing.add(task.id);
|
|
228
|
+
try {
|
|
229
|
+
core.markScheduledTaskRunning(tasks, task.id, new Date());
|
|
230
|
+
await saveTasks();
|
|
231
|
+
const result = await executeTask(task, ctx);
|
|
232
|
+
core.markScheduledTaskFired(tasks, task.id, new Date(), result);
|
|
233
|
+
await saveTasks();
|
|
234
|
+
} catch (error: any) {
|
|
235
|
+
core.markScheduledTaskFailed(tasks, task.id, new Date(), error);
|
|
236
|
+
await saveTasks();
|
|
237
|
+
const message = `Scheduled task ${task.id} failed: ${error?.message ?? String(error)}`;
|
|
238
|
+
if (ctx.hasUI) ctx.ui.notify(message, "error");
|
|
239
|
+
recordMessage(`⚠️ ${message}`, { task, error: error?.message ?? String(error) }, false);
|
|
240
|
+
} finally {
|
|
241
|
+
firing.delete(task.id);
|
|
242
|
+
rescheduleAll(ctx);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function createAndSchedule(input: Record<string, any>, ctx: ExtensionContext): Promise<ScheduledTask> {
|
|
247
|
+
const task = core.createScheduledTask(
|
|
248
|
+
{
|
|
249
|
+
...input,
|
|
250
|
+
whenText: input.whenText ?? input.when,
|
|
251
|
+
cwd: input.cwd ?? ctx.cwd,
|
|
252
|
+
sessionFile: currentSessionFile(ctx),
|
|
253
|
+
},
|
|
254
|
+
new Date(),
|
|
255
|
+
);
|
|
256
|
+
tasks.push(task);
|
|
257
|
+
await saveTasks();
|
|
258
|
+
rescheduleAll(ctx);
|
|
259
|
+
return task;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function parseCommandTask(args: string, ctx: ExtensionContext): Record<string, any> {
|
|
263
|
+
const parsed = core.splitScheduleCommand(args, new Date());
|
|
264
|
+
const base: Record<string, any> = { action: parsed.action, whenText: parsed.whenText, cwd: ctx.cwd };
|
|
265
|
+
if (parsed.action === "prompt") return { ...base, prompt: parsed.payload };
|
|
266
|
+
if (parsed.action === "shell") return { ...base, command: parsed.payload };
|
|
267
|
+
return { ...base, message: parsed.payload };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
pi.registerMessageRenderer("scheduled-task", (message, options, theme) => {
|
|
271
|
+
let text = `${theme.fg("accent", theme.bold("scheduled"))} ${message.content}`;
|
|
272
|
+
if (options.expanded && message.details) {
|
|
273
|
+
text += `\n${theme.fg("dim", JSON.stringify(message.details, null, 2))}`;
|
|
274
|
+
}
|
|
275
|
+
return new Text(text, 0, 0);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
279
|
+
activeCtx = ctx;
|
|
280
|
+
await loadTasks();
|
|
281
|
+
rescheduleAll(ctx);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
285
|
+
clearTimers();
|
|
286
|
+
if (ctx.hasUI) ctx.ui.setStatus("scheduler", undefined);
|
|
287
|
+
activeCtx = undefined;
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
pi.registerCommand("schedule", {
|
|
291
|
+
description: "Schedule a notify, prompt, shell command, or message action",
|
|
292
|
+
handler: async (args, ctx) => {
|
|
293
|
+
if (!args.trim()) {
|
|
294
|
+
ctx.ui.notify("Usage: /schedule [notify|prompt|shell|message] <when> :: <payload>", "warning");
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
const task = await createAndSchedule(parseCommandTask(args, ctx), ctx);
|
|
299
|
+
ctx.ui.notify(taskCreatedText(task), "info");
|
|
300
|
+
recordMessage(taskCreatedText(task), { task }, false);
|
|
301
|
+
} catch (error: any) {
|
|
302
|
+
ctx.ui.notify(error?.message ?? String(error), "error");
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
pi.registerCommand("remind", {
|
|
308
|
+
description: "Alias for /schedule notify",
|
|
309
|
+
handler: async (args, ctx) => {
|
|
310
|
+
if (!args.trim()) {
|
|
311
|
+
ctx.ui.notify("Usage: /remind <when> <message>", "warning");
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
const parsed = core.splitScheduleCommand(`notify ${args}`, new Date());
|
|
316
|
+
const task = await createAndSchedule(
|
|
317
|
+
{ action: "notify", whenText: parsed.whenText, message: parsed.payload, cwd: ctx.cwd },
|
|
318
|
+
ctx,
|
|
319
|
+
);
|
|
320
|
+
ctx.ui.notify(taskCreatedText(task), "info");
|
|
321
|
+
recordMessage(taskCreatedText(task), { task }, false);
|
|
322
|
+
} catch (error: any) {
|
|
323
|
+
ctx.ui.notify(error?.message ?? String(error), "error");
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
pi.registerCommand("schedules", {
|
|
329
|
+
description: "List scheduled tasks; pass 'all' to include fired/cancelled/failed tasks",
|
|
330
|
+
handler: async (args, ctx) => {
|
|
331
|
+
await loadTasks();
|
|
332
|
+
const includeAll = args.trim().toLowerCase() === "all";
|
|
333
|
+
const visible = tasks.filter((task) => taskBelongsToSession(task, ctx));
|
|
334
|
+
recordMessage(core.formatTaskList(visible, new Date(), { includeAll }), { includeAll, tasks: visible }, false);
|
|
335
|
+
updateStatus(ctx);
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
pi.registerCommand("schedule-cancel", {
|
|
340
|
+
description: "Cancel a pending scheduled task by id or id prefix",
|
|
341
|
+
handler: async (args, ctx) => {
|
|
342
|
+
const id = args.trim();
|
|
343
|
+
if (!id) {
|
|
344
|
+
ctx.ui.notify("Usage: /schedule-cancel <id>", "warning");
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
await loadTasks();
|
|
349
|
+
const visible = tasks.filter((task) => taskBelongsToSession(task, ctx));
|
|
350
|
+
const task = core.cancelScheduledTask(visible, id, new Date());
|
|
351
|
+
await saveTasks();
|
|
352
|
+
rescheduleAll(ctx);
|
|
353
|
+
ctx.ui.notify(`Cancelled scheduled task ${task.id}`, "info");
|
|
354
|
+
recordMessage(`Cancelled scheduled task ${task.id}`, { task }, false);
|
|
355
|
+
} catch (error: any) {
|
|
356
|
+
ctx.ui.notify(error?.message ?? String(error), "error");
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
pi.registerTool({
|
|
362
|
+
name: "schedule_task",
|
|
363
|
+
label: "Schedule Task",
|
|
364
|
+
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",
|
|
367
|
+
promptGuidelines: [
|
|
368
|
+
"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.",
|
|
372
|
+
],
|
|
373
|
+
parameters: Type.Object({
|
|
374
|
+
action: StringEnum(ACTIONS, {
|
|
375
|
+
description: "What to do at the scheduled time. Use prompt to wake the agent.",
|
|
376
|
+
default: "prompt",
|
|
377
|
+
}),
|
|
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
|
+
}),
|
|
381
|
+
message: Type.Optional(Type.String({ description: "Message for notify/message actions." })),
|
|
382
|
+
prompt: Type.Optional(Type.String({ description: "User prompt to inject for prompt actions." })),
|
|
383
|
+
command: Type.Optional(Type.String({ description: "Shell command to run for shell actions." })),
|
|
384
|
+
payload: Type.Optional(Type.String({ description: "Generic payload fallback for any action." })),
|
|
385
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for shell actions; defaults to current cwd." })),
|
|
386
|
+
timeoutMs: Type.Optional(Type.Number({ description: "Shell timeout in milliseconds.", minimum: 1000 })),
|
|
387
|
+
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
|
+
}),
|
|
392
|
+
),
|
|
393
|
+
title: Type.Optional(Type.String({ description: "Optional human-readable title." })),
|
|
394
|
+
triggerTurn: Type.Optional(
|
|
395
|
+
Type.Boolean({ description: "For message actions: whether the message should trigger an agent turn. Default true." }),
|
|
396
|
+
),
|
|
397
|
+
}),
|
|
398
|
+
prepareArguments(args) {
|
|
399
|
+
if (!args || typeof args !== "object") return args;
|
|
400
|
+
const input = args as Record<string, any>;
|
|
401
|
+
if (input.when === undefined && typeof input.whenText === "string") {
|
|
402
|
+
return { ...input, when: input.whenText };
|
|
403
|
+
}
|
|
404
|
+
return args;
|
|
405
|
+
},
|
|
406
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
407
|
+
const task = await createAndSchedule(params, ctx);
|
|
408
|
+
return {
|
|
409
|
+
content: [{ type: "text", text: taskCreatedText(task) }],
|
|
410
|
+
details: { task, pending: core.pendingTasks(tasks) },
|
|
411
|
+
};
|
|
412
|
+
},
|
|
413
|
+
renderCall(args, theme) {
|
|
414
|
+
return new Text(
|
|
415
|
+
`${theme.fg("toolTitle", theme.bold("schedule_task"))} ${theme.fg("muted", args.action ?? "prompt")} ${theme.fg("accent", args.when ?? "")}`,
|
|
416
|
+
0,
|
|
417
|
+
0,
|
|
418
|
+
);
|
|
419
|
+
},
|
|
420
|
+
renderResult(result, _options, theme) {
|
|
421
|
+
const text = result.content?.[0];
|
|
422
|
+
return new Text(theme.fg("success", "✓ ") + (text?.type === "text" ? text.text : "Scheduled"), 0, 0);
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
pi.registerTool({
|
|
427
|
+
name: "list_scheduled_tasks",
|
|
428
|
+
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",
|
|
431
|
+
parameters: Type.Object({
|
|
432
|
+
includeAll: Type.Optional(Type.Boolean({ description: "Include fired, cancelled, and failed tasks. Default false." })),
|
|
433
|
+
}),
|
|
434
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
435
|
+
await loadTasks();
|
|
436
|
+
const visible = tasks.filter((task) => taskBelongsToSession(task, ctx));
|
|
437
|
+
const text = core.formatTaskList(visible, new Date(), { includeAll: Boolean(params.includeAll) });
|
|
438
|
+
updateStatus(ctx);
|
|
439
|
+
return { content: [{ type: "text", text }], details: { tasks: visible } };
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
pi.registerTool({
|
|
444
|
+
name: "cancel_scheduled_task",
|
|
445
|
+
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",
|
|
448
|
+
parameters: Type.Object({
|
|
449
|
+
id: Type.String({ description: "Task id or unique id prefix." }),
|
|
450
|
+
}),
|
|
451
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
452
|
+
await loadTasks();
|
|
453
|
+
const visible = tasks.filter((task) => taskBelongsToSession(task, ctx));
|
|
454
|
+
const task = core.cancelScheduledTask(visible, params.id, new Date());
|
|
455
|
+
await saveTasks();
|
|
456
|
+
rescheduleAll(ctx);
|
|
457
|
+
return {
|
|
458
|
+
content: [{ type: "text", text: `Cancelled scheduled task ${task.id}` }],
|
|
459
|
+
details: { task, pending: core.pendingTasks(tasks) },
|
|
460
|
+
};
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
const VALID_ACTIONS = new Set(["notify", "prompt", "shell", "message"]);
|
|
2
|
+
const VALID_STATUSES = new Set(["pending", "running", "fired", "cancelled", "failed"]);
|
|
3
|
+
|
|
4
|
+
const SECOND = 1000;
|
|
5
|
+
const MINUTE = 60 * SECOND;
|
|
6
|
+
const HOUR = 60 * MINUTE;
|
|
7
|
+
const DAY = 24 * HOUR;
|
|
8
|
+
const WEEK = 7 * DAY;
|
|
9
|
+
|
|
10
|
+
function asDate(value) {
|
|
11
|
+
const date = value instanceof Date ? new Date(value.getTime()) : new Date(value);
|
|
12
|
+
if (Number.isNaN(date.getTime())) throw new Error(`Invalid date: ${value}`);
|
|
13
|
+
return date;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function compactSpaces(text) {
|
|
17
|
+
return String(text ?? "").trim().replace(/\s+/g, " ");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function unitToMs(unit) {
|
|
21
|
+
const u = unit.toLowerCase();
|
|
22
|
+
if (u === "s" || u.startsWith("sec")) return SECOND;
|
|
23
|
+
if (u === "m" || u.startsWith("min")) return MINUTE;
|
|
24
|
+
if (u === "h" || u.startsWith("hr") || u.startsWith("hour")) return HOUR;
|
|
25
|
+
if (u === "d" || u.startsWith("day")) return DAY;
|
|
26
|
+
if (u === "w" || u.startsWith("week")) return WEEK;
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseDurationMs(text) {
|
|
31
|
+
let input = compactSpaces(text).toLowerCase();
|
|
32
|
+
if (!input) return null;
|
|
33
|
+
input = input.replace(/^in\s+/, "").replace(/,/g, " ").replace(/\band\b/g, " ").replace(/\s+/g, " ").trim();
|
|
34
|
+
if (!input) return null;
|
|
35
|
+
|
|
36
|
+
const re = /(\d+(?:\.\d+)?)\s*(weeks?|w|days?|d|hours?|hrs?|hr|h|minutes?|mins?|min|m|seconds?|secs?|sec|s)/gi;
|
|
37
|
+
let total = 0;
|
|
38
|
+
let matched = false;
|
|
39
|
+
let cursor = 0;
|
|
40
|
+
let match;
|
|
41
|
+
|
|
42
|
+
while ((match = re.exec(input)) !== null) {
|
|
43
|
+
const between = input.slice(cursor, match.index);
|
|
44
|
+
if (between.trim() !== "") return null;
|
|
45
|
+
const value = Number(match[1]);
|
|
46
|
+
const unitMs = unitToMs(match[2]);
|
|
47
|
+
if (!Number.isFinite(value) || value <= 0 || !unitMs) return null;
|
|
48
|
+
total += value * unitMs;
|
|
49
|
+
matched = true;
|
|
50
|
+
cursor = re.lastIndex;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!matched || input.slice(cursor).trim() !== "") return null;
|
|
54
|
+
return Math.round(total);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseTimeToken(text, options = {}) {
|
|
58
|
+
const input = compactSpaces(text).toLowerCase();
|
|
59
|
+
const match = input.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i);
|
|
60
|
+
if (!match) return null;
|
|
61
|
+
|
|
62
|
+
const hasColon = match[2] !== undefined;
|
|
63
|
+
const suffix = match[3]?.toLowerCase();
|
|
64
|
+
if (!hasColon && !suffix && !options.allowBareHour) return null;
|
|
65
|
+
|
|
66
|
+
let hour = Number(match[1]);
|
|
67
|
+
const minute = match[2] === undefined ? 0 : Number(match[2]);
|
|
68
|
+
if (!Number.isInteger(minute) || minute < 0 || minute > 59) return null;
|
|
69
|
+
|
|
70
|
+
if (suffix) {
|
|
71
|
+
if (hour < 1 || hour > 12) return null;
|
|
72
|
+
if (suffix === "am") hour = hour === 12 ? 0 : hour;
|
|
73
|
+
if (suffix === "pm") hour = hour === 12 ? 12 : hour + 12;
|
|
74
|
+
} else {
|
|
75
|
+
if (hour < 0 || hour > 23) return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { hour, minute };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseClockExpression(text, now) {
|
|
82
|
+
let input = compactSpaces(text).toLowerCase();
|
|
83
|
+
if (!input) return null;
|
|
84
|
+
|
|
85
|
+
let dayOffset;
|
|
86
|
+
let hadAt = false;
|
|
87
|
+
|
|
88
|
+
if (input === "tomorrow") return now.getTime() + DAY;
|
|
89
|
+
if (input === "today") return now.getTime();
|
|
90
|
+
|
|
91
|
+
if (input.startsWith("tomorrow ")) {
|
|
92
|
+
dayOffset = 1;
|
|
93
|
+
input = input.slice("tomorrow".length).trim();
|
|
94
|
+
} else if (input.startsWith("today ")) {
|
|
95
|
+
dayOffset = 0;
|
|
96
|
+
input = input.slice("today".length).trim();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (input.startsWith("at ")) {
|
|
100
|
+
hadAt = true;
|
|
101
|
+
input = input.slice(3).trim();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!input) return null;
|
|
105
|
+
const parsed = parseTimeToken(input, { allowBareHour: hadAt || dayOffset !== undefined });
|
|
106
|
+
if (!parsed) return null;
|
|
107
|
+
|
|
108
|
+
const target = new Date(now.getTime());
|
|
109
|
+
if (dayOffset !== undefined) target.setDate(target.getDate() + dayOffset);
|
|
110
|
+
target.setHours(parsed.hour, parsed.minute, 0, 0);
|
|
111
|
+
|
|
112
|
+
if (dayOffset === undefined && target.getTime() <= now.getTime()) target.setDate(target.getDate() + 1);
|
|
113
|
+
return target.getTime();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseWhen(text, nowValue = new Date()) {
|
|
117
|
+
const now = asDate(nowValue);
|
|
118
|
+
const input = compactSpaces(text);
|
|
119
|
+
if (!input) throw new Error("Scheduled time is required");
|
|
120
|
+
|
|
121
|
+
const durationMs = parseDurationMs(input);
|
|
122
|
+
if (durationMs !== null) {
|
|
123
|
+
return { dueAtMs: now.getTime() + durationMs, kind: "relative", normalized: input };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const clockMs = parseClockExpression(input, now);
|
|
127
|
+
if (clockMs !== null) {
|
|
128
|
+
if (clockMs <= now.getTime()) throw new Error(`Scheduled time is in the past: ${input}`);
|
|
129
|
+
return { dueAtMs: clockMs, kind: "clock", normalized: input };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const absoluteMs = Date.parse(input);
|
|
133
|
+
if (!Number.isNaN(absoluteMs)) {
|
|
134
|
+
if (absoluteMs <= now.getTime()) throw new Error(`Scheduled time is in the past: ${input}`);
|
|
135
|
+
return { dueAtMs: absoluteMs, kind: "absolute", normalized: input };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
throw new Error(`Could not parse scheduled time: ${input}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function normalizeAction(action) {
|
|
142
|
+
const value = compactSpaces(action || "notify").toLowerCase();
|
|
143
|
+
if (!VALID_ACTIONS.has(value)) throw new Error(`Invalid scheduled action: ${value}`);
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function splitScheduleCommand(args, nowValue = new Date()) {
|
|
148
|
+
const text = compactSpaces(args);
|
|
149
|
+
if (!text) throw new Error("Usage: /schedule [notify|prompt|shell|message] <when> <payload>");
|
|
150
|
+
|
|
151
|
+
const separatorIndex = text.indexOf("::");
|
|
152
|
+
if (separatorIndex >= 0) {
|
|
153
|
+
const left = compactSpaces(text.slice(0, separatorIndex));
|
|
154
|
+
const payload = compactSpaces(text.slice(separatorIndex + 2));
|
|
155
|
+
if (!left || !payload) throw new Error("Usage with separator: /schedule [action] <when> :: <payload>");
|
|
156
|
+
const leftTokens = left.split(" ");
|
|
157
|
+
let action = "notify";
|
|
158
|
+
let whenText = left;
|
|
159
|
+
if (VALID_ACTIONS.has(leftTokens[0].toLowerCase())) {
|
|
160
|
+
action = leftTokens[0].toLowerCase();
|
|
161
|
+
whenText = compactSpaces(leftTokens.slice(1).join(" "));
|
|
162
|
+
}
|
|
163
|
+
parseWhen(whenText, nowValue);
|
|
164
|
+
return { action, whenText, payload };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const tokens = text.split(" ");
|
|
168
|
+
let action = "notify";
|
|
169
|
+
let restTokens = tokens;
|
|
170
|
+
if (VALID_ACTIONS.has(tokens[0].toLowerCase())) {
|
|
171
|
+
action = tokens[0].toLowerCase();
|
|
172
|
+
restTokens = tokens.slice(1);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const maxPrefix = Math.min(restTokens.length - 1, 10);
|
|
176
|
+
let bestMatch = null;
|
|
177
|
+
for (let i = 1; i <= maxPrefix; i++) {
|
|
178
|
+
const whenText = restTokens.slice(0, i).join(" ");
|
|
179
|
+
const payload = restTokens.slice(i).join(" ").trim();
|
|
180
|
+
if (!payload) continue;
|
|
181
|
+
try {
|
|
182
|
+
parseWhen(whenText, nowValue);
|
|
183
|
+
bestMatch = { action, whenText, payload };
|
|
184
|
+
} catch {
|
|
185
|
+
// Try a longer prefix.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (bestMatch) return bestMatch;
|
|
189
|
+
|
|
190
|
+
throw new Error("Could not split scheduled task. Try: /schedule prompt 5m summarize progress");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function generateId(nowValue = new Date()) {
|
|
194
|
+
const now = asDate(nowValue);
|
|
195
|
+
return `task_${now.getTime().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function validateTimeoutMs(value) {
|
|
199
|
+
if (value === undefined || value === null) return undefined;
|
|
200
|
+
const n = Number(value);
|
|
201
|
+
if (!Number.isFinite(n) || n <= 0) throw new Error("timeoutMs must be a positive number");
|
|
202
|
+
return Math.round(n);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function createScheduledTask(input, nowValue = new Date(), idFn = generateId) {
|
|
206
|
+
const now = asDate(nowValue);
|
|
207
|
+
const action = normalizeAction(input.action);
|
|
208
|
+
const whenText = compactSpaces(input.whenText ?? input.when ?? input.due ?? "");
|
|
209
|
+
const parsed = parseWhen(whenText, now);
|
|
210
|
+
const task = {
|
|
211
|
+
id: idFn(now),
|
|
212
|
+
action,
|
|
213
|
+
status: "pending",
|
|
214
|
+
createdAt: now.toISOString(),
|
|
215
|
+
dueAt: new Date(parsed.dueAtMs).toISOString(),
|
|
216
|
+
whenText,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const message = compactSpaces(input.message ?? input.payload ?? "");
|
|
220
|
+
const prompt = compactSpaces(input.prompt ?? input.payload ?? input.message ?? "");
|
|
221
|
+
const command = compactSpaces(input.command ?? input.payload ?? "");
|
|
222
|
+
|
|
223
|
+
if (input.title !== undefined) task.title = compactSpaces(input.title);
|
|
224
|
+
if (input.cwd) task.cwd = String(input.cwd);
|
|
225
|
+
if (input.sessionFile) task.sessionFile = String(input.sessionFile);
|
|
226
|
+
|
|
227
|
+
if (action === "notify") {
|
|
228
|
+
if (!message) throw new Error("message is required for notify scheduled tasks");
|
|
229
|
+
task.message = message;
|
|
230
|
+
} else if (action === "prompt") {
|
|
231
|
+
if (!prompt) throw new Error("prompt is required for prompt scheduled tasks");
|
|
232
|
+
task.prompt = prompt;
|
|
233
|
+
} else if (action === "shell") {
|
|
234
|
+
if (!command) throw new Error("command is required for shell scheduled tasks");
|
|
235
|
+
task.command = command;
|
|
236
|
+
const timeoutMs = validateTimeoutMs(input.timeoutMs);
|
|
237
|
+
if (timeoutMs !== undefined) task.timeoutMs = timeoutMs;
|
|
238
|
+
const followUpPrompt = compactSpaces(input.followUpPrompt ?? "");
|
|
239
|
+
if (followUpPrompt) task.followUpPrompt = followUpPrompt;
|
|
240
|
+
} else if (action === "message") {
|
|
241
|
+
if (!message) throw new Error("message is required for message scheduled tasks");
|
|
242
|
+
task.message = message;
|
|
243
|
+
if (input.triggerTurn !== undefined) task.triggerTurn = Boolean(input.triggerTurn);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return task;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function taskSummary(task) {
|
|
250
|
+
const raw = task.command ?? task.prompt ?? task.message ?? "";
|
|
251
|
+
return raw.length > 100 ? `${raw.slice(0, 97)}...` : raw;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function isTaskShape(task) {
|
|
255
|
+
if (!task || typeof task !== "object") return false;
|
|
256
|
+
if (typeof task.id !== "string" || !VALID_ACTIONS.has(task.action)) return false;
|
|
257
|
+
if (!VALID_STATUSES.has(task.status)) return false;
|
|
258
|
+
if (Number.isNaN(Date.parse(task.dueAt))) return false;
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function sanitizeTasks(value) {
|
|
263
|
+
if (!Array.isArray(value)) return [];
|
|
264
|
+
return value.filter(isTaskShape);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function pendingTasks(tasks) {
|
|
268
|
+
return tasks
|
|
269
|
+
.filter((task) => task.status === "pending")
|
|
270
|
+
.slice()
|
|
271
|
+
.sort((a, b) => Date.parse(a.dueAt) - Date.parse(b.dueAt));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function dueTasks(tasks, nowValue = new Date()) {
|
|
275
|
+
const now = asDate(nowValue).getTime();
|
|
276
|
+
return pendingTasks(tasks).filter((task) => Date.parse(task.dueAt) <= now);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function findTask(tasks, idOrPrefix) {
|
|
280
|
+
const id = compactSpaces(idOrPrefix);
|
|
281
|
+
return tasks.find((task) => task.id === id || task.id.startsWith(id));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function cancelScheduledTask(tasks, idOrPrefix, nowValue = new Date()) {
|
|
285
|
+
const now = asDate(nowValue);
|
|
286
|
+
const task = findTask(tasks, idOrPrefix);
|
|
287
|
+
if (!task) throw new Error(`Scheduled task not found: ${idOrPrefix}`);
|
|
288
|
+
if (task.status !== "pending") throw new Error(`Scheduled task ${task.id} is already ${task.status}`);
|
|
289
|
+
task.status = "cancelled";
|
|
290
|
+
task.cancelledAt = now.toISOString();
|
|
291
|
+
return task;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function markScheduledTaskRunning(tasks, idOrPrefix, nowValue = new Date()) {
|
|
295
|
+
const now = asDate(nowValue);
|
|
296
|
+
const task = findTask(tasks, idOrPrefix);
|
|
297
|
+
if (!task) throw new Error(`Scheduled task not found: ${idOrPrefix}`);
|
|
298
|
+
if (task.status !== "pending") return task;
|
|
299
|
+
task.status = "running";
|
|
300
|
+
task.startedAt = now.toISOString();
|
|
301
|
+
return task;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function markScheduledTaskFired(tasks, idOrPrefix, nowValue = new Date(), result) {
|
|
305
|
+
const now = asDate(nowValue);
|
|
306
|
+
const task = findTask(tasks, idOrPrefix);
|
|
307
|
+
if (!task) throw new Error(`Scheduled task not found: ${idOrPrefix}`);
|
|
308
|
+
if (task.status === "cancelled") return task;
|
|
309
|
+
task.status = "fired";
|
|
310
|
+
task.firedAt = now.toISOString();
|
|
311
|
+
if (result !== undefined) task.result = result;
|
|
312
|
+
return task;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function markScheduledTaskFailed(tasks, idOrPrefix, nowValue = new Date(), error) {
|
|
316
|
+
const now = asDate(nowValue);
|
|
317
|
+
const task = findTask(tasks, idOrPrefix);
|
|
318
|
+
if (!task) throw new Error(`Scheduled task not found: ${idOrPrefix}`);
|
|
319
|
+
if (task.status === "cancelled") return task;
|
|
320
|
+
task.status = "failed";
|
|
321
|
+
task.failedAt = now.toISOString();
|
|
322
|
+
task.error = error instanceof Error ? error.message : String(error);
|
|
323
|
+
return task;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function formatRelativeTime(dueAt, nowValue = new Date()) {
|
|
327
|
+
const now = asDate(nowValue).getTime();
|
|
328
|
+
let diff = Date.parse(dueAt) - now;
|
|
329
|
+
if (!Number.isFinite(diff) || diff <= 0) return "due now";
|
|
330
|
+
|
|
331
|
+
const parts = [];
|
|
332
|
+
const units = [
|
|
333
|
+
["d", DAY],
|
|
334
|
+
["h", HOUR],
|
|
335
|
+
["m", MINUTE],
|
|
336
|
+
["s", SECOND],
|
|
337
|
+
];
|
|
338
|
+
for (const [label, size] of units) {
|
|
339
|
+
const value = Math.floor(diff / size);
|
|
340
|
+
if (value > 0) {
|
|
341
|
+
parts.push(`${value}${label}`);
|
|
342
|
+
diff -= value * size;
|
|
343
|
+
}
|
|
344
|
+
if (parts.length >= 2) break;
|
|
345
|
+
}
|
|
346
|
+
return `in ${parts.join(" ") || "<1s"}`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function formatTaskLine(task, nowValue = new Date()) {
|
|
350
|
+
const due = new Date(task.dueAt);
|
|
351
|
+
const status = task.status === "pending" ? formatRelativeTime(task.dueAt, nowValue) : task.status;
|
|
352
|
+
return `- ${task.id} ${status} (${due.toLocaleString()}) [${task.action}] ${taskSummary(task)}`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function formatTaskList(tasks, nowValue = new Date(), options = {}) {
|
|
356
|
+
const list = options.includeAll
|
|
357
|
+
? tasks.slice().sort((a, b) => Date.parse(a.dueAt) - Date.parse(b.dueAt))
|
|
358
|
+
: pendingTasks(tasks);
|
|
359
|
+
if (list.length === 0) return options.includeAll ? "No scheduled tasks." : "No pending scheduled tasks.";
|
|
360
|
+
const title = options.includeAll ? "Scheduled tasks:" : "Pending scheduled tasks:";
|
|
361
|
+
return [title, ...list.map((task) => formatTaskLine(task, nowValue))].join("\n");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
module.exports = {
|
|
365
|
+
VALID_ACTIONS,
|
|
366
|
+
VALID_STATUSES,
|
|
367
|
+
SECOND,
|
|
368
|
+
MINUTE,
|
|
369
|
+
HOUR,
|
|
370
|
+
DAY,
|
|
371
|
+
parseDurationMs,
|
|
372
|
+
parseWhen,
|
|
373
|
+
splitScheduleCommand,
|
|
374
|
+
normalizeAction,
|
|
375
|
+
generateId,
|
|
376
|
+
createScheduledTask,
|
|
377
|
+
sanitizeTasks,
|
|
378
|
+
pendingTasks,
|
|
379
|
+
dueTasks,
|
|
380
|
+
cancelScheduledTask,
|
|
381
|
+
markScheduledTaskRunning,
|
|
382
|
+
markScheduledTaskFired,
|
|
383
|
+
markScheduledTaskFailed,
|
|
384
|
+
formatRelativeTime,
|
|
385
|
+
formatTaskLine,
|
|
386
|
+
formatTaskList,
|
|
387
|
+
taskSummary,
|
|
388
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jl1990/pi-scheduler",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Give Pi coding agents a clock: schedule reminders, shell commands, and self-waking prompts for CI polling and autonomous follow-ups.",
|
|
5
|
+
"author": "jl1990",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/jl1990/pi-scheduler.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/jl1990/pi-scheduler#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/jl1990/pi-scheduler/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"pi-package",
|
|
17
|
+
"pi",
|
|
18
|
+
"pi-coding-agent",
|
|
19
|
+
"coding-agent",
|
|
20
|
+
"ai-agents",
|
|
21
|
+
"scheduler",
|
|
22
|
+
"automation",
|
|
23
|
+
"reminders",
|
|
24
|
+
"ci-cd",
|
|
25
|
+
"developer-tools",
|
|
26
|
+
"typescript"
|
|
27
|
+
],
|
|
28
|
+
"files": [
|
|
29
|
+
"extensions/**/*",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE.md"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"pi": {
|
|
37
|
+
"extensions": [
|
|
38
|
+
"./extensions/scheduler/index.ts"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@earendil-works/pi-ai": "*",
|
|
43
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
44
|
+
"@earendil-works/pi-tui": "*",
|
|
45
|
+
"typebox": "*"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"@earendil-works/pi-ai": {
|
|
49
|
+
"optional": true
|
|
50
|
+
},
|
|
51
|
+
"@earendil-works/pi-coding-agent": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"@earendil-works/pi-tui": {
|
|
55
|
+
"optional": true
|
|
56
|
+
},
|
|
57
|
+
"typebox": {
|
|
58
|
+
"optional": true
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"test": "node --test test/*.test.cjs",
|
|
63
|
+
"pack:dry-run": "npm pack --dry-run"
|
|
64
|
+
}
|
|
65
|
+
}
|