@hank-warren/pi-loop 0.7.0 → 0.9.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/CHANGELOG.md +69 -0
- package/README.md +18 -37
- package/package.json +4 -1
- package/skills/pi-loop/SKILL.md +54 -2
- package/src/fresh-launch.ts +128 -0
- package/src/index.ts +38 -84
- package/src/interval.ts +25 -0
- package/src/ledger.ts +187 -6
- package/src/loop-action-menus.ts +129 -0
- package/src/loop-env.ts +50 -0
- package/src/loop.ts +299 -47
- package/src/manager.ts +126 -0
- package/src/objective.ts +25 -2
- package/src/planning.ts +108 -0
- package/src/presentation.ts +50 -0
- package/src/progress-tool.ts +162 -0
- package/src/propose-tool.ts +130 -0
- package/src/state.ts +15 -0
- package/src/widget.ts +102 -12
- package/src/schedule/command.ts +0 -255
- package/src/schedule/cron.ts +0 -182
- package/src/schedule/manager.ts +0 -129
- package/src/schedule/model.ts +0 -237
- package/src/schedule/runner.ts +0 -351
- package/src/schedule/store.ts +0 -183
package/src/schedule/command.ts
DELETED
|
@@ -1,255 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Deterministic `/schedule` parsing. Grammar:
|
|
3
|
-
*
|
|
4
|
-
* /schedule manager TUI
|
|
5
|
-
* /schedule list
|
|
6
|
-
* /schedule every <dur> [flags] <prompt...>
|
|
7
|
-
* /schedule at <ISO|+dur> [flags] <prompt...>
|
|
8
|
-
* /schedule cron "<m h dom mon dow>" [flags] <prompt...>
|
|
9
|
-
* /schedule pause|resume|delete|run|status <id>
|
|
10
|
-
*
|
|
11
|
-
* Flags: --run (headless instead of an in-session prompt), --cwd <path>,
|
|
12
|
-
* --max <n|unlimited>, --wake always|failure|success|never, --name <text>.
|
|
13
|
-
*
|
|
14
|
-
* The extension owns this grammar, never the model — same reason `/loop`
|
|
15
|
-
* does. A model that can schedule its own future turns can schedule its way
|
|
16
|
-
* around every limit the loop imposes.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { parseDuration } from "../interval.js";
|
|
20
|
-
import { parseCron } from "./cron.js";
|
|
21
|
-
import {
|
|
22
|
-
MIN_INTERVAL_MS,
|
|
23
|
-
type ScheduleSpec,
|
|
24
|
-
type TaskSpec,
|
|
25
|
-
WAKE_ON_VALUES,
|
|
26
|
-
type WakeOn,
|
|
27
|
-
} from "./model.js";
|
|
28
|
-
|
|
29
|
-
export const SCHEDULE_SUBCOMMANDS = [
|
|
30
|
-
"list",
|
|
31
|
-
"pause",
|
|
32
|
-
"resume",
|
|
33
|
-
"delete",
|
|
34
|
-
"run",
|
|
35
|
-
"status",
|
|
36
|
-
] as const;
|
|
37
|
-
export type ScheduleSubcommand = (typeof SCHEDULE_SUBCOMMANDS)[number];
|
|
38
|
-
|
|
39
|
-
export interface ScheduleCreateCommand {
|
|
40
|
-
kind: "create";
|
|
41
|
-
name: string;
|
|
42
|
-
schedule: ScheduleSpec;
|
|
43
|
-
task: TaskSpec;
|
|
44
|
-
maxRuns?: number | null;
|
|
45
|
-
/** Set when a sub-minute interval was raised to the floor. */
|
|
46
|
-
clampedFrom?: number;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export type ScheduleCommand =
|
|
50
|
-
| { kind: "show" }
|
|
51
|
-
| { kind: "list" }
|
|
52
|
-
| { kind: ScheduleSubcommand; id: string }
|
|
53
|
-
| ScheduleCreateCommand
|
|
54
|
-
| { kind: "error"; message: string };
|
|
55
|
-
|
|
56
|
-
export interface ParseScheduleOptions {
|
|
57
|
-
/** Default working directory for a headless run. */
|
|
58
|
-
cwd: string;
|
|
59
|
-
now?: number;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function parseScheduleCommand(
|
|
63
|
-
args: string,
|
|
64
|
-
options: ParseScheduleOptions,
|
|
65
|
-
): ScheduleCommand {
|
|
66
|
-
const trimmed = args.trim();
|
|
67
|
-
if (!trimmed) return { kind: "show" };
|
|
68
|
-
const tokens = [...args.matchAll(/\S+/g)].map((match) => ({
|
|
69
|
-
text: match[0],
|
|
70
|
-
index: match.index,
|
|
71
|
-
}));
|
|
72
|
-
const head = tokens[0]?.text ?? "";
|
|
73
|
-
if (head === "list") return { kind: "list" };
|
|
74
|
-
if ((SCHEDULE_SUBCOMMANDS as readonly string[]).includes(head)) {
|
|
75
|
-
const id = tokens[1]?.text;
|
|
76
|
-
if (!id) return { kind: "error", message: `/schedule ${head} needs a task id.` };
|
|
77
|
-
return { kind: head as ScheduleSubcommand, id };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const now = options.now ?? Date.now();
|
|
81
|
-
let position = 1;
|
|
82
|
-
let schedule: ScheduleSpec | undefined;
|
|
83
|
-
let clampedFrom: number | undefined;
|
|
84
|
-
if (head === "every") {
|
|
85
|
-
const value = tokens[1]?.text;
|
|
86
|
-
if (!value) return { kind: "error", message: "/schedule every needs a duration, e.g. 30m." };
|
|
87
|
-
const everyMs = parseDuration(value);
|
|
88
|
-
if (everyMs === undefined) {
|
|
89
|
-
return {
|
|
90
|
-
kind: "error",
|
|
91
|
-
message: `Invalid interval: ${value}. Use <number><unit> with unit s, m, h, or d.`,
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
if (everyMs < MIN_INTERVAL_MS) clampedFrom = everyMs;
|
|
95
|
-
schedule = { kind: "interval", everyMs: Math.max(MIN_INTERVAL_MS, everyMs) };
|
|
96
|
-
position = 2;
|
|
97
|
-
} else if (head === "at") {
|
|
98
|
-
const value = tokens[1]?.text;
|
|
99
|
-
if (!value) {
|
|
100
|
-
return {
|
|
101
|
-
kind: "error",
|
|
102
|
-
message: "/schedule at needs a time: an ISO timestamp, or +<duration> such as +90m.",
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
const at = parseAt(value, now);
|
|
106
|
-
if (at === undefined) {
|
|
107
|
-
return {
|
|
108
|
-
kind: "error",
|
|
109
|
-
message: `Invalid time: ${value}. Use an ISO timestamp (2026-01-31T09:00) or +<duration> such as +90m.`,
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
if (at <= now) return { kind: "error", message: `That time is in the past: ${value}.` };
|
|
113
|
-
schedule = { kind: "once", at };
|
|
114
|
-
position = 2;
|
|
115
|
-
} else if (head === "cron") {
|
|
116
|
-
const quoted = readQuoted(args, tokens[1]?.index ?? 0);
|
|
117
|
-
const expression = quoted?.value ?? tokens.slice(1, 6).map((token) => token.text).join(" ");
|
|
118
|
-
const parsed = parseCron(expression);
|
|
119
|
-
if (!parsed.ok) return { kind: "error", message: `Invalid cron expression: ${parsed.error}.` };
|
|
120
|
-
schedule = { kind: "cron", expression: parsed.spec.expression };
|
|
121
|
-
position = quoted ? tokenIndexAfter(tokens, quoted.end) : 6;
|
|
122
|
-
} else {
|
|
123
|
-
return {
|
|
124
|
-
kind: "error",
|
|
125
|
-
message: `Unknown /schedule form: ${head}. Use every <dur>, at <time>, cron "<expr>", list, pause, resume, run, status, or delete.`,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
let headless = false;
|
|
130
|
-
let cwd = options.cwd;
|
|
131
|
-
let wakeOn: WakeOn = "failure";
|
|
132
|
-
let maxRuns: number | null | undefined;
|
|
133
|
-
let name: string | undefined;
|
|
134
|
-
while (position < tokens.length) {
|
|
135
|
-
const token = tokens[position];
|
|
136
|
-
if (token === undefined || !token.text.startsWith("--")) break;
|
|
137
|
-
const [flag, inline] = splitFlag(token.text);
|
|
138
|
-
const value = inline ?? tokens[position + 1]?.text;
|
|
139
|
-
if (flag === "--run") {
|
|
140
|
-
headless = true;
|
|
141
|
-
position += 1;
|
|
142
|
-
continue;
|
|
143
|
-
}
|
|
144
|
-
if (value === undefined) return { kind: "error", message: `${flag} needs a value.` };
|
|
145
|
-
if (flag === "--cwd") {
|
|
146
|
-
cwd = value;
|
|
147
|
-
} else if (flag === "--wake") {
|
|
148
|
-
if (!WAKE_ON_VALUES.includes(value as WakeOn)) {
|
|
149
|
-
return {
|
|
150
|
-
kind: "error",
|
|
151
|
-
message: `Invalid --wake value: ${value}. Use ${WAKE_ON_VALUES.join(", ")}.`,
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
wakeOn = value as WakeOn;
|
|
155
|
-
} else if (flag === "--max") {
|
|
156
|
-
if (value === "unlimited" || value === "null") maxRuns = null;
|
|
157
|
-
else {
|
|
158
|
-
const parsed = Number(value);
|
|
159
|
-
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
160
|
-
return {
|
|
161
|
-
kind: "error",
|
|
162
|
-
message: `Invalid --max value: ${value}. Use a positive whole number or unlimited.`,
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
maxRuns = parsed;
|
|
166
|
-
}
|
|
167
|
-
} else if (flag === "--name") {
|
|
168
|
-
name = value;
|
|
169
|
-
} else {
|
|
170
|
-
return {
|
|
171
|
-
kind: "error",
|
|
172
|
-
message: `Unknown flag: ${flag}. Known flags: --run, --cwd, --max, --wake, --name.`,
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
position += inline === undefined ? 2 : 1;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const promptToken = tokens[position];
|
|
179
|
-
const prompt = promptToken === undefined ? "" : args.slice(promptToken.index).trim();
|
|
180
|
-
if (!prompt) {
|
|
181
|
-
return { kind: "error", message: "A scheduled task needs a prompt to run." };
|
|
182
|
-
}
|
|
183
|
-
return {
|
|
184
|
-
kind: "create",
|
|
185
|
-
name: name ?? summarize(prompt),
|
|
186
|
-
schedule,
|
|
187
|
-
task: headless ? { kind: "run", prompt, cwd, wakeOn } : { kind: "prompt", prompt },
|
|
188
|
-
...(maxRuns === undefined ? {} : { maxRuns }),
|
|
189
|
-
...(clampedFrom === undefined ? {} : { clampedFrom }),
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function parseAt(value: string, now: number): number | undefined {
|
|
194
|
-
if (value.startsWith("+")) {
|
|
195
|
-
const delay = parseDuration(value.slice(1));
|
|
196
|
-
return delay === undefined ? undefined : now + delay;
|
|
197
|
-
}
|
|
198
|
-
const parsed = Date.parse(value);
|
|
199
|
-
return Number.isFinite(parsed) ? parsed : undefined;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/** Read a quoted string starting at `from`, or undefined when unquoted. */
|
|
203
|
-
function readQuoted(args: string, from: number): { value: string; end: number } | undefined {
|
|
204
|
-
const quote = args[from];
|
|
205
|
-
if (quote !== '"' && quote !== "'") return undefined;
|
|
206
|
-
const end = args.indexOf(quote, from + 1);
|
|
207
|
-
if (end === -1) return undefined;
|
|
208
|
-
return { value: args.slice(from + 1, end), end };
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function tokenIndexAfter(
|
|
212
|
-
tokens: ReadonlyArray<{ text: string; index: number }>,
|
|
213
|
-
offset: number,
|
|
214
|
-
): number {
|
|
215
|
-
for (let index = 0; index < tokens.length; index += 1) {
|
|
216
|
-
const token = tokens[index];
|
|
217
|
-
if (token && token.index > offset) return index;
|
|
218
|
-
}
|
|
219
|
-
return tokens.length;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function splitFlag(token: string): [string, string | undefined] {
|
|
223
|
-
const equals = token.indexOf("=");
|
|
224
|
-
if (equals === -1) return [token, undefined];
|
|
225
|
-
return [token.slice(0, equals), token.slice(equals + 1)];
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function summarize(prompt: string): string {
|
|
229
|
-
const collapsed = prompt.replace(/\s+/gu, " ").trim();
|
|
230
|
-
return collapsed.length <= 60 ? collapsed : `${collapsed.slice(0, 59)}…`;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
export interface ScheduleArgumentCompletion {
|
|
234
|
-
value: string;
|
|
235
|
-
label: string;
|
|
236
|
-
description?: string;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const COMPLETIONS: readonly ScheduleArgumentCompletion[] = [
|
|
240
|
-
{ value: "every", label: "every", description: "Repeat on an interval, e.g. every 30m" },
|
|
241
|
-
{ value: "at", label: "at", description: "Fire once, e.g. at +90m or an ISO timestamp" },
|
|
242
|
-
{ value: "cron", label: "cron", description: 'Fire on a cron expression, e.g. cron "0 9 * * 1"' },
|
|
243
|
-
{ value: "list", label: "list", description: "List scheduled tasks" },
|
|
244
|
-
{ value: "pause", label: "pause", description: "Pause a task" },
|
|
245
|
-
{ value: "resume", label: "resume", description: "Resume a paused task" },
|
|
246
|
-
{ value: "run", label: "run", description: "Run a task now" },
|
|
247
|
-
{ value: "status", label: "status", description: "Show one task in detail" },
|
|
248
|
-
{ value: "delete", label: "delete", description: "Delete a task" },
|
|
249
|
-
];
|
|
250
|
-
|
|
251
|
-
export function completeScheduleArguments(prefix: string): ScheduleArgumentCompletion[] | null {
|
|
252
|
-
const trimmed = prefix.trimStart();
|
|
253
|
-
const matches = COMPLETIONS.filter((candidate) => candidate.value.startsWith(trimmed));
|
|
254
|
-
return matches.length > 0 ? matches : null;
|
|
255
|
-
}
|
package/src/schedule/cron.ts
DELETED
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A five-field cron parser, minute granularity, stdlib only.
|
|
3
|
-
*
|
|
4
|
-
* Deliberately small and numeric: `minute hour day-of-month month
|
|
5
|
-
* day-of-week`, each field `*`, a number, `a-b`, `a,b,c`, `*/n`, or
|
|
6
|
-
* `a-b/n`. No names, no `@daily`, no seconds, no timezones beyond the host's
|
|
7
|
-
* local time. Every one of those is a place where two implementations
|
|
8
|
-
* disagree, and a scheduler whose semantics are debatable is worse than one
|
|
9
|
-
* that refuses the expression.
|
|
10
|
-
*
|
|
11
|
-
* Day-of-month and day-of-week follow the traditional cron rule: when both
|
|
12
|
-
* are restricted, a day matching *either* fires. That is genuinely surprising
|
|
13
|
-
* behaviour, but it is what every crontab in the world means, and inventing a
|
|
14
|
-
* more sensible rule here would be the bigger trap.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
export interface CronSpec {
|
|
18
|
-
expression: string;
|
|
19
|
-
minutes: ReadonlySet<number>;
|
|
20
|
-
hours: ReadonlySet<number>;
|
|
21
|
-
daysOfMonth: ReadonlySet<number>;
|
|
22
|
-
months: ReadonlySet<number>;
|
|
23
|
-
daysOfWeek: ReadonlySet<number>;
|
|
24
|
-
/** Both day fields restricted: match either, per traditional cron. */
|
|
25
|
-
dayUnion: boolean;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
interface FieldRange {
|
|
29
|
-
min: number;
|
|
30
|
-
max: number;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const FIELDS: ReadonlyArray<{ name: string; range: FieldRange }> = [
|
|
34
|
-
{ name: "minute", range: { min: 0, max: 59 } },
|
|
35
|
-
{ name: "hour", range: { min: 0, max: 23 } },
|
|
36
|
-
{ name: "day-of-month", range: { min: 1, max: 31 } },
|
|
37
|
-
{ name: "month", range: { min: 1, max: 12 } },
|
|
38
|
-
{ name: "day-of-week", range: { min: 0, max: 7 } },
|
|
39
|
-
];
|
|
40
|
-
|
|
41
|
-
export type CronParseResult =
|
|
42
|
-
| { ok: true; spec: CronSpec }
|
|
43
|
-
| { ok: false; error: string };
|
|
44
|
-
|
|
45
|
-
export function parseCron(expression: string): CronParseResult {
|
|
46
|
-
const fields = expression.trim().split(/\s+/u);
|
|
47
|
-
if (fields.length !== 5) {
|
|
48
|
-
return {
|
|
49
|
-
ok: false,
|
|
50
|
-
error: `a cron expression has 5 fields (minute hour day-of-month month day-of-week), got ${fields.length}`,
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
const parsed: Array<Set<number>> = [];
|
|
54
|
-
for (let index = 0; index < FIELDS.length; index += 1) {
|
|
55
|
-
const field = FIELDS[index];
|
|
56
|
-
const text = fields[index];
|
|
57
|
-
if (!field || text === undefined) return { ok: false, error: "malformed cron expression" };
|
|
58
|
-
const values = parseField(text, field.range);
|
|
59
|
-
if (!values) return { ok: false, error: `invalid ${field.name} field: ${text}` };
|
|
60
|
-
parsed.push(values);
|
|
61
|
-
}
|
|
62
|
-
const [minutes, hours, daysOfMonth, months, rawDaysOfWeek] = parsed as [
|
|
63
|
-
Set<number>,
|
|
64
|
-
Set<number>,
|
|
65
|
-
Set<number>,
|
|
66
|
-
Set<number>,
|
|
67
|
-
Set<number>,
|
|
68
|
-
];
|
|
69
|
-
// 7 and 0 are both Sunday.
|
|
70
|
-
const daysOfWeek = new Set([...rawDaysOfWeek].map((day) => (day === 7 ? 0 : day)));
|
|
71
|
-
return {
|
|
72
|
-
ok: true,
|
|
73
|
-
spec: {
|
|
74
|
-
expression: fields.join(" "),
|
|
75
|
-
minutes,
|
|
76
|
-
hours,
|
|
77
|
-
daysOfMonth,
|
|
78
|
-
months,
|
|
79
|
-
daysOfWeek,
|
|
80
|
-
dayUnion: fields[2] !== "*" && fields[4] !== "*",
|
|
81
|
-
},
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function parseField(text: string, range: FieldRange): Set<number> | undefined {
|
|
86
|
-
const values = new Set<number>();
|
|
87
|
-
for (const part of text.split(",")) {
|
|
88
|
-
const [spec, stepText] = part.split("/");
|
|
89
|
-
if (spec === undefined || spec === "") return undefined;
|
|
90
|
-
let step = 1;
|
|
91
|
-
if (stepText !== undefined) {
|
|
92
|
-
step = Number(stepText);
|
|
93
|
-
if (!Number.isSafeInteger(step) || step <= 0) return undefined;
|
|
94
|
-
}
|
|
95
|
-
let from: number;
|
|
96
|
-
let to: number;
|
|
97
|
-
if (spec === "*") {
|
|
98
|
-
from = range.min;
|
|
99
|
-
to = range.max;
|
|
100
|
-
} else if (spec.includes("-")) {
|
|
101
|
-
const [fromText, toText, ...rest] = spec.split("-");
|
|
102
|
-
if (rest.length > 0) return undefined;
|
|
103
|
-
from = Number(fromText);
|
|
104
|
-
to = Number(toText);
|
|
105
|
-
} else {
|
|
106
|
-
from = Number(spec);
|
|
107
|
-
to = from;
|
|
108
|
-
}
|
|
109
|
-
if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to) || from > to) return undefined;
|
|
110
|
-
if (from < range.min || to > range.max) return undefined;
|
|
111
|
-
for (let value = from; value <= to; value += step) values.add(value);
|
|
112
|
-
}
|
|
113
|
-
return values.size > 0 ? values : undefined;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Whether a local-time date matches the spec, to the minute. */
|
|
117
|
-
export function cronMatches(spec: CronSpec, date: Date): boolean {
|
|
118
|
-
if (!spec.minutes.has(date.getMinutes())) return false;
|
|
119
|
-
if (!spec.hours.has(date.getHours())) return false;
|
|
120
|
-
if (!spec.months.has(date.getMonth() + 1)) return false;
|
|
121
|
-
const domMatch = spec.daysOfMonth.has(date.getDate());
|
|
122
|
-
const dowMatch = spec.daysOfWeek.has(date.getDay());
|
|
123
|
-
return spec.dayUnion ? domMatch || dowMatch : domMatch && dowMatch;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** How far ahead a next-fire search gives up, in days. */
|
|
127
|
-
const SEARCH_LIMIT_DAYS = 5 * 366;
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* The next local-time minute at or after `after` that matches, or undefined
|
|
131
|
-
* when the expression can never fire again (30 February and friends).
|
|
132
|
-
*
|
|
133
|
-
* Scans day by day and only walks minutes inside a matching day, so an
|
|
134
|
-
* expression that fires once a year costs a few thousand cheap comparisons
|
|
135
|
-
* rather than half a million.
|
|
136
|
-
*/
|
|
137
|
-
export function nextCronFire(spec: CronSpec, after: number): number | undefined {
|
|
138
|
-
const cursor = new Date(after);
|
|
139
|
-
cursor.setSeconds(0, 0);
|
|
140
|
-
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
141
|
-
for (let day = 0; day <= SEARCH_LIMIT_DAYS; day += 1) {
|
|
142
|
-
if (dayCouldMatch(spec, cursor)) {
|
|
143
|
-
const hit = firstMatchingMinuteOfDay(spec, cursor, day === 0);
|
|
144
|
-
if (hit !== undefined) return hit;
|
|
145
|
-
}
|
|
146
|
-
cursor.setDate(cursor.getDate() + 1);
|
|
147
|
-
cursor.setHours(0, 0, 0, 0);
|
|
148
|
-
}
|
|
149
|
-
return undefined;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function dayCouldMatch(spec: CronSpec, date: Date): boolean {
|
|
153
|
-
if (!spec.months.has(date.getMonth() + 1)) return false;
|
|
154
|
-
const domMatch = spec.daysOfMonth.has(date.getDate());
|
|
155
|
-
const dowMatch = spec.daysOfWeek.has(date.getDay());
|
|
156
|
-
return spec.dayUnion ? domMatch || dowMatch : domMatch && dowMatch;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function firstMatchingMinuteOfDay(
|
|
160
|
-
spec: CronSpec,
|
|
161
|
-
dayStart: Date,
|
|
162
|
-
respectCursorTime: boolean,
|
|
163
|
-
): number | undefined {
|
|
164
|
-
const startHour = respectCursorTime ? dayStart.getHours() : 0;
|
|
165
|
-
const startMinute = respectCursorTime ? dayStart.getMinutes() : 0;
|
|
166
|
-
for (const hour of sorted(spec.hours)) {
|
|
167
|
-
if (hour < startHour) continue;
|
|
168
|
-
for (const minute of sorted(spec.minutes)) {
|
|
169
|
-
if (hour === startHour && minute < startMinute) continue;
|
|
170
|
-
const candidate = new Date(dayStart);
|
|
171
|
-
candidate.setHours(hour, minute, 0, 0);
|
|
172
|
-
// A DST jump can move the wall clock off the requested slot; the
|
|
173
|
-
// match check is authoritative.
|
|
174
|
-
if (cronMatches(spec, candidate)) return candidate.getTime();
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
return undefined;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function sorted(values: ReadonlySet<number>): number[] {
|
|
181
|
-
return [...values].sort((a, b) => a - b);
|
|
182
|
-
}
|
package/src/schedule/manager.ts
DELETED
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The `/schedule` manager, in the same shape as the `/loop` manager: Pi's
|
|
3
|
-
* native dialog primitives, and a plain notification in non-TUI modes so
|
|
4
|
-
* every action is reachable without a menu.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import { describeSchedule, nextFireAt, type ScheduledTask } from "./model.js";
|
|
9
|
-
import type { Scheduler } from "./runner.js";
|
|
10
|
-
|
|
11
|
-
export async function showScheduleManager(
|
|
12
|
-
scheduler: Scheduler,
|
|
13
|
-
ctx: ExtensionCommandContext,
|
|
14
|
-
): Promise<void> {
|
|
15
|
-
if (ctx.mode !== "tui") {
|
|
16
|
-
ctx.ui.notify(listTasks(scheduler).join("\n"), "info");
|
|
17
|
-
return;
|
|
18
|
-
}
|
|
19
|
-
for (;;) {
|
|
20
|
-
const tasks = scheduler.tasks();
|
|
21
|
-
if (tasks.length === 0) {
|
|
22
|
-
ctx.ui.notify(
|
|
23
|
-
'No scheduled tasks. Create one with /schedule every 30m <prompt>, /schedule at +2h <prompt>, or /schedule cron "0 9 * * 1" <prompt>.',
|
|
24
|
-
"info",
|
|
25
|
-
);
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
const rows = tasks.map((task) => summarizeRow(task));
|
|
29
|
-
const choice = await ctx.ui.select("Scheduled tasks", rows);
|
|
30
|
-
if (choice === undefined) return;
|
|
31
|
-
const task = tasks[rows.indexOf(choice)];
|
|
32
|
-
if (!task) return;
|
|
33
|
-
if (!(await manageTask(scheduler, ctx, task))) return;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Returns false when the manager should close. */
|
|
38
|
-
async function manageTask(
|
|
39
|
-
scheduler: Scheduler,
|
|
40
|
-
ctx: ExtensionCommandContext,
|
|
41
|
-
task: ScheduledTask,
|
|
42
|
-
): Promise<boolean> {
|
|
43
|
-
const actions = [
|
|
44
|
-
"Details",
|
|
45
|
-
task.status === "paused" ? "Resume" : "Pause",
|
|
46
|
-
"Run now",
|
|
47
|
-
"Delete",
|
|
48
|
-
"Back",
|
|
49
|
-
];
|
|
50
|
-
const action = await ctx.ui.select(`${task.name} · ${task.status}`, actions);
|
|
51
|
-
if (action === undefined || action === "Back") return true;
|
|
52
|
-
switch (action) {
|
|
53
|
-
case "Details":
|
|
54
|
-
ctx.ui.notify(describeTask(task).join("\n"), "info");
|
|
55
|
-
return true;
|
|
56
|
-
case "Pause":
|
|
57
|
-
scheduler.update({ ...task, status: "paused" });
|
|
58
|
-
ctx.ui.notify(`Paused "${task.name}".`, "info");
|
|
59
|
-
return true;
|
|
60
|
-
case "Resume":
|
|
61
|
-
scheduler.update({ ...task, status: "active" });
|
|
62
|
-
ctx.ui.notify(`Resumed "${task.name}".`, "info");
|
|
63
|
-
return true;
|
|
64
|
-
case "Run now":
|
|
65
|
-
scheduler.fireNow(task);
|
|
66
|
-
ctx.ui.notify(`Running "${task.name}" now.`, "info");
|
|
67
|
-
return true;
|
|
68
|
-
case "Delete": {
|
|
69
|
-
const confirmed = await ctx.ui.confirm("Delete task?", `Delete "${task.name}"?`);
|
|
70
|
-
if (!confirmed) return true;
|
|
71
|
-
scheduler.remove(task.id);
|
|
72
|
-
ctx.ui.notify(`Deleted "${task.name}".`, "info");
|
|
73
|
-
return true;
|
|
74
|
-
}
|
|
75
|
-
default:
|
|
76
|
-
return true;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function listTasks(scheduler: Scheduler): string[] {
|
|
81
|
-
const tasks = scheduler.tasks();
|
|
82
|
-
if (tasks.length === 0) {
|
|
83
|
-
return [
|
|
84
|
-
"No scheduled tasks.",
|
|
85
|
-
'Create one with /schedule every 30m <prompt>, /schedule at +2h <prompt>, or /schedule cron "0 9 * * 1" <prompt>.',
|
|
86
|
-
];
|
|
87
|
-
}
|
|
88
|
-
return tasks.map((task) => summarizeRow(task));
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function summarizeRow(task: ScheduledTask): string {
|
|
92
|
-
const next = nextFireAt(task, Date.now());
|
|
93
|
-
// A finished or paused task has no "next" to report; saying "next done"
|
|
94
|
-
// reads as though something were still scheduled.
|
|
95
|
-
const when =
|
|
96
|
-
task.status !== "active"
|
|
97
|
-
? task.status
|
|
98
|
-
: next === undefined
|
|
99
|
-
? "never again"
|
|
100
|
-
: `next ${new Date(next).toLocaleString()}`;
|
|
101
|
-
const result = task.lastResult ? ` · last ${task.lastResult.ok ? "ok" : "failed"}` : "";
|
|
102
|
-
return `${task.id} · ${task.task.kind} · ${describeSchedule(task.schedule)} · ${when}${result} · ${task.name}`;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export function describeTask(task: ScheduledTask): string[] {
|
|
106
|
-
const next = nextFireAt(task, Date.now());
|
|
107
|
-
const lines = [
|
|
108
|
-
`Task: ${task.name}`,
|
|
109
|
-
`Id: ${task.id}`,
|
|
110
|
-
`Kind: ${task.task.kind === "run" ? "headless run" : "in-session prompt"}`,
|
|
111
|
-
`Schedule: ${describeSchedule(task.schedule)}`,
|
|
112
|
-
`Status: ${task.status}`,
|
|
113
|
-
`Runs: ${task.runs}${task.maxRuns === null ? " (unlimited)" : ` of ${task.maxRuns}`}`,
|
|
114
|
-
`Next fire: ${next === undefined ? "never again" : new Date(next).toLocaleString()}`,
|
|
115
|
-
`Expires: ${new Date(task.expiresAt).toLocaleString()}`,
|
|
116
|
-
];
|
|
117
|
-
if (task.task.kind === "run") {
|
|
118
|
-
lines.push(`Working directory: ${task.task.cwd}`, `Wake the session: ${task.task.wakeOn}`);
|
|
119
|
-
}
|
|
120
|
-
if (task.lastResult) {
|
|
121
|
-
lines.push(
|
|
122
|
-
`Last result: ${task.lastResult.ok ? "ok" : "failed"} at ${new Date(task.lastResult.at).toLocaleString()}${
|
|
123
|
-
task.lastResult.detail ? ` — ${task.lastResult.detail}` : ""
|
|
124
|
-
}`,
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
lines.push(`Prompt: ${task.task.prompt}`);
|
|
128
|
-
return lines;
|
|
129
|
-
}
|