pi-orchestrator-config 4.3.12 → 4.3.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -66
- package/extensions/coms/coms-p2p.ts +53 -16
- package/extensions/orchestrator/async-agents.ts +50 -24
- package/extensions/orchestrator/cron-status-format.ts +6 -6
- package/extensions/orchestrator/cron-status-ui.ts +3 -3
- package/extensions/orchestrator/cron-store.ts +208 -0
- package/extensions/orchestrator/cron.ts +168 -699
- package/extensions/orchestrator/extended-autocomplete.ts +27 -27
- package/extensions/orchestrator/status.ts +7 -2
- package/extensions/pidash/pidash-ui/dist/assets/{index-BoNVhD5K.js → index-Ehfwjr9d.js} +1 -1
- package/extensions/pidash/pidash-ui/dist/index.html +1 -1
- package/extensions/pidash/pidash-ui/src/components/InfoBar.tsx +3 -7
- package/extensions/pidash/pidash-ui/src/components/MessageList.tsx +0 -4
- package/extensions/pidash/pidash-ui/tsconfig.json +1 -0
- package/extensions/pidiff/pidiff-ui/package.json +1 -1
- package/extensions/pitasks/index.ts +2 -5
- package/package.json +2 -1
|
@@ -1,736 +1,205 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cron-like scheduled tasks — pi-process-scoped.
|
|
3
|
-
*
|
|
4
|
-
* Tasks survive /reload, /resume, /new but die on pi exit.
|
|
5
|
-
* Persisted to a session-scoped file so they survive extension re-evaluation.
|
|
6
|
-
*
|
|
7
|
-
* The /cron command is a natural-language interface — the AI parses
|
|
8
|
-
* the user's intent and calls the cron_manage tool with structured params.
|
|
9
|
-
*
|
|
10
|
-
* Slash command tasks (starting with /) execute as commands.
|
|
11
|
-
* Prompt tasks run as async agents with triggerTurn: true.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { createLogger } from "../shared/logger.js";
|
|
15
|
-
|
|
16
|
-
const log = createLogger("cron");
|
|
1
|
+
/** Cron tasks with session and project scopes. Durable project delivery is at-least-once. */
|
|
17
2
|
import * as fs from "node:fs";
|
|
18
|
-
import * as os from "node:os";
|
|
19
3
|
import * as path from "node:path";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { getProjectTmpDir } from "./utils.js";
|
|
20
6
|
import { Type } from "typebox";
|
|
21
7
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { createLogger } from "../shared/logger.js";
|
|
22
9
|
import { decideAsyncLlmDispatch } from "./async-capability.js";
|
|
23
|
-
import { discoverAgents } from "./agents.js";
|
|
24
10
|
import { formatCronSchedule, toCronStatusTaskView } from "./cron-status-format.js";
|
|
25
11
|
import { openCronStatusOverlay } from "./cron-status-ui.js";
|
|
26
12
|
import { setSlot } from "./status-bar.js";
|
|
27
|
-
import {
|
|
28
|
-
|
|
29
|
-
// ── Types ────────────────────────────────────────────────────────────
|
|
30
|
-
|
|
31
|
-
export interface CronTask {
|
|
32
|
-
id: number;
|
|
33
|
-
description: string; // human-readable description
|
|
34
|
-
task: string; // what to execute
|
|
35
|
-
intervalMs?: number; // for interval-based
|
|
36
|
-
atHour?: number; // for time-based (daily)
|
|
37
|
-
atMinute?: number;
|
|
38
|
-
createdAt: number;
|
|
39
|
-
lastRun?: number;
|
|
40
|
-
nextRun?: number;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Shared mutable state + helpers passed to extracted registration functions */
|
|
44
|
-
interface CronInternals {
|
|
45
|
-
tasks: Map<number, CronTask>;
|
|
46
|
-
nextId: number;
|
|
47
|
-
lastCwd: string;
|
|
48
|
-
lastCtx: any;
|
|
49
|
-
startTask(task: CronTask): void;
|
|
50
|
-
stopTask(id: number): void;
|
|
51
|
-
updateCronStatus(): void;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// ── Persistence ──────────────────────────────────────────────────────
|
|
55
|
-
|
|
56
|
-
let CRON_FILE = ""; // Set on session_start to project-scoped dir
|
|
57
|
-
/** Ignore fs.watch echoes from our own writes. */
|
|
58
|
-
let CRON_IGNORE_WATCH_UNTIL = 0;
|
|
13
|
+
import { acquireLeaderLock, durableCronSupported, mutateDurableCronStore, readDurableCronStore, refreshLeaderLock, releaseLeaderLock, validateDurableCronTask, type CronLockOwner, type CronScope, type DurableCronTask } from "./cron-store.js";
|
|
59
14
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
const seed = `${process.pid}-${process.ppid}-${process.argv.join(",")}`;
|
|
71
|
-
let hash = 0;
|
|
72
|
-
for (let i = 0; i < seed.length; i++) {
|
|
73
|
-
hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0;
|
|
74
|
-
}
|
|
75
|
-
return `${process.pid}-${Math.abs(hash).toString(36)}`;
|
|
76
|
-
}
|
|
77
|
-
})();
|
|
78
|
-
|
|
79
|
-
/** Matches both old (cron-{pid}.json) and new (cron-{pid}-{suffix}.json) formats */
|
|
80
|
-
const CRON_FILE_RE = /^cron-(\d+)(?:-[^.]+)?\.json$/;
|
|
81
|
-
|
|
82
|
-
/** Get current cron file path — used by autocomplete */
|
|
83
|
-
export function getCronFilePath(): string { return CRON_FILE; }
|
|
84
|
-
|
|
85
|
-
function saveCrons(tasks: CronTask[]): void {
|
|
86
|
-
if (!CRON_FILE) return;
|
|
87
|
-
try {
|
|
88
|
-
CRON_IGNORE_WATCH_UNTIL = Date.now() + 400;
|
|
89
|
-
fs.writeFileSync(CRON_FILE, JSON.stringify(tasks), { mode: 0o600 });
|
|
90
|
-
} catch (e: any) { log.debug("save crons failed:", e?.message || e); }
|
|
15
|
+
const log = createLogger("cron");
|
|
16
|
+
export interface CronTask { id: string; scope: CronScope; cwd: string; description: string; task: string; intervalMs?: number; atHour?: number; atMinute?: number; createdAt: number; lastRun?: number; nextRun?: number; leader?: boolean; }
|
|
17
|
+
type Timer = ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>;
|
|
18
|
+
const durable = (scope: CronScope) => scope === "project";
|
|
19
|
+
let sessionStore = "";
|
|
20
|
+
function sessionStoreFor(ctx: any): string {
|
|
21
|
+
let id: unknown;
|
|
22
|
+
try { id = ctx.sessionManager?.getSessionId?.(); } catch { return ""; }
|
|
23
|
+
// A process ID survives /new. Only pi's session ID preserves session scope across /reload.
|
|
24
|
+
return typeof id === "string" && id ? path.join(getProjectTmpDir(ctx.cwd), `cron-${createHash("sha256").update(id).digest("hex")}.json`) : "";
|
|
91
25
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (!CRON_FILE) return [];
|
|
95
|
-
try {
|
|
96
|
-
const data = JSON.parse(fs.readFileSync(CRON_FILE, "utf-8"));
|
|
97
|
-
return Array.isArray(data) ? data : [];
|
|
98
|
-
} catch {
|
|
99
|
-
return [];
|
|
100
|
-
}
|
|
26
|
+
function readSessionTasks(file: string): unknown[] {
|
|
27
|
+
try { const value = JSON.parse(fs.readFileSync(file, "utf8")); return Array.isArray(value) ? value : []; } catch { return []; }
|
|
101
28
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
for (const f of fs.readdirSync(dir)) {
|
|
108
|
-
const m = f.match(CRON_FILE_RE);
|
|
109
|
-
if (!m || f === myFile) continue;
|
|
110
|
-
// Old format (no suffix) — always delete (legacy)
|
|
111
|
-
const isLegacy = /^cron-\d+\.json$/.test(f);
|
|
112
|
-
if (isLegacy) {
|
|
113
|
-
try { fs.unlinkSync(path.join(dir, f)); } catch {}
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
// New format — extract PID and suffix token
|
|
117
|
-
const pid = +m[1];
|
|
118
|
-
const suffixMatch = f.match(/^cron-\d+-(.+)\.json$/);
|
|
119
|
-
const fileToken = suffixMatch?.[1];
|
|
120
|
-
// Check /proc/{pid}/stat — if we can read it, verify start time matches
|
|
121
|
-
try {
|
|
122
|
-
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
123
|
-
const startTime = parseProcStartTime(stat);
|
|
124
|
-
if (startTime && fileToken && startTime === fileToken) {
|
|
125
|
-
// Same PID, same start time — process is alive, skip
|
|
126
|
-
continue;
|
|
127
|
-
}
|
|
128
|
-
if (startTime && fileToken && startTime !== fileToken) {
|
|
129
|
-
// PID reused — different process, safe to delete
|
|
130
|
-
try { fs.unlinkSync(path.join(dir, f)); } catch {}
|
|
131
|
-
continue;
|
|
132
|
-
}
|
|
133
|
-
} catch {
|
|
134
|
-
// /proc/{pid} not readable — either dead process or different PID namespace
|
|
135
|
-
// Only delete if we can confirm PID is dead in our namespace
|
|
136
|
-
try { process.kill(pid, 0); } catch {
|
|
137
|
-
// PID dead in our namespace — safe to delete
|
|
138
|
-
try { fs.unlinkSync(path.join(dir, f)); } catch {}
|
|
139
|
-
}
|
|
140
|
-
// If kill(pid, 0) succeeds but /proc is unreadable — skip (can't verify)
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
} catch (e: any) { log.debug("cleanup orphaned cron files failed:", e?.message || e); }
|
|
29
|
+
function validateSessionCronTask(task: unknown): asserts task is CronTask {
|
|
30
|
+
const value = task as CronTask;
|
|
31
|
+
if (!value || typeof value !== "object" || typeof value.id !== "string" || !value.id || typeof value.description !== "string" || typeof value.task !== "string" || !value.task.trim() || !Number.isFinite(value.createdAt)) throw new Error("Invalid session cron task");
|
|
32
|
+
validateSchedule({ interval_seconds: value.intervalMs === undefined ? undefined : value.intervalMs / 1000, at_hour: value.atHour, at_minute: value.atMinute });
|
|
33
|
+
if ((value.lastRun !== undefined && !Number.isFinite(value.lastRun)) || (value.nextRun !== undefined && !Number.isFinite(value.nextRun))) throw new Error("Invalid session cron timestamp");
|
|
144
34
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
function formatSchedule(task: CronTask): string {
|
|
149
|
-
return formatCronSchedule(task);
|
|
35
|
+
function writeSessionTasks(file: string, tasks: Iterable<CronTask>) {
|
|
36
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
37
|
+
fs.writeFileSync(file, JSON.stringify([...tasks]), { mode: 0o600 });
|
|
150
38
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
39
|
+
export const qualifyCronId = (task: Pick<CronTask, "scope" | "id">) => `${task.scope === "project" ? "persist" : "session"}:${task.id}`;
|
|
40
|
+
let currentCronTasks: () => Iterable<CronTask> = () => [];
|
|
41
|
+
export function cronRemoveAutocompleteItems(tasks: Iterable<Pick<CronTask, "scope" | "id" | "description" | "task">>) {
|
|
42
|
+
const seen = new Set<string>();
|
|
43
|
+
const items = [];
|
|
44
|
+
for (const task of tasks) {
|
|
45
|
+
const id = qualifyCronId(task);
|
|
46
|
+
if (!seen.has(id)) { seen.add(id); items.push({ value: id, label: id, description: task.description || task.task }); }
|
|
158
47
|
}
|
|
159
|
-
return
|
|
48
|
+
return items;
|
|
160
49
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
description: "The action to perform",
|
|
171
|
-
}),
|
|
172
|
-
description: Type.Optional(Type.String({
|
|
173
|
-
description: "Human-readable description of the task (for 'add')",
|
|
174
|
-
})),
|
|
175
|
-
task: Type.Optional(Type.String({
|
|
176
|
-
description: "What to execute — a prompt for the AI or a /slash-command (for 'add')",
|
|
177
|
-
})),
|
|
178
|
-
interval_seconds: Type.Optional(Type.Number({
|
|
179
|
-
description: "Run every N seconds (for interval-based 'add'). Minimum 10 seconds.",
|
|
180
|
-
})),
|
|
181
|
-
at_hour: Type.Optional(Type.Number({
|
|
182
|
-
description: "Hour (0-23) for daily time-based schedule (for 'add')",
|
|
183
|
-
})),
|
|
184
|
-
at_minute: Type.Optional(Type.Number({
|
|
185
|
-
description: "Minute (0-59) for daily time-based schedule (for 'add')",
|
|
186
|
-
})),
|
|
187
|
-
id: Type.Optional(Type.Number({
|
|
188
|
-
description: "Task ID to remove (for 'remove')",
|
|
189
|
-
})),
|
|
190
|
-
}),
|
|
191
|
-
async execute(_id, params) {
|
|
192
|
-
const action = params.action as string;
|
|
193
|
-
|
|
194
|
-
if (action === "add") {
|
|
195
|
-
// Block scheduling in one-shot modes
|
|
196
|
-
if (state.lastCtx?.mode === "print" || state.lastCtx?.mode === "json") {
|
|
197
|
-
return { content: [{ type: "text", text: "Error: cron scheduling is not available in one-shot modes (print/json)" }] };
|
|
198
|
-
}
|
|
199
|
-
if (!params.task) {
|
|
200
|
-
return { content: [{ type: "text", text: "Error: 'task' is required for add action" }] };
|
|
201
|
-
}
|
|
202
|
-
if (!params.interval_seconds && params.at_hour === undefined) {
|
|
203
|
-
return { content: [{ type: "text", text: "Error: either 'interval_seconds' or 'at_hour'+'at_minute' is required" }] };
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const intervalMs = params.interval_seconds ? Math.max(10, params.interval_seconds) * 1000 : undefined;
|
|
207
|
-
const atHour = params.at_hour !== undefined ? Math.max(0, Math.min(23, params.at_hour)) : undefined;
|
|
208
|
-
const atMinute = params.at_minute !== undefined ? Math.max(0, Math.min(59, params.at_minute)) : (atHour !== undefined ? 0 : undefined);
|
|
209
|
-
|
|
210
|
-
const task: CronTask = {
|
|
211
|
-
id: state.nextId++,
|
|
212
|
-
description: (params.description as string) || (params.task as string).slice(0, 60),
|
|
213
|
-
task: params.task as string,
|
|
214
|
-
intervalMs,
|
|
215
|
-
atHour,
|
|
216
|
-
atMinute,
|
|
217
|
-
createdAt: Date.now(),
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
state.tasks.set(task.id, task);
|
|
221
|
-
state.startTask(task);
|
|
222
|
-
saveCrons([...state.tasks.values()]);
|
|
223
|
-
log.info("cron_add", task.description);
|
|
224
|
-
|
|
225
|
-
state.updateCronStatus();
|
|
226
|
-
return {
|
|
227
|
-
content: [{
|
|
228
|
-
type: "text",
|
|
229
|
-
text: `Cron #${task.id} created: ${formatSchedule(task)} → ${task.description}`,
|
|
230
|
-
}],
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
if (action === "list") {
|
|
235
|
-
if (state.tasks.size === 0) {
|
|
236
|
-
return { content: [{ type: "text", text: "No scheduled tasks." }] };
|
|
237
|
-
}
|
|
238
|
-
const lines = [...state.tasks.values()].map(t => {
|
|
239
|
-
const last = t.lastRun ? new Date(t.lastRun).toLocaleTimeString() : "never";
|
|
240
|
-
return `#${t.id} | ${formatSchedule(t)} | ${t.description} | last run: ${last}`;
|
|
241
|
-
});
|
|
242
|
-
return {
|
|
243
|
-
content: [{ type: "text", text: `Scheduled tasks:\n\n${lines.join("\n")}` }],
|
|
244
|
-
};
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
if (action === "list-all") {
|
|
248
|
-
const sections: string[] = [];
|
|
249
|
-
if (!CRON_FILE) {
|
|
250
|
-
return { content: [{ type: "text", text: "No scheduled tasks in any session." }] };
|
|
251
|
-
}
|
|
252
|
-
try {
|
|
253
|
-
const dir = path.dirname(CRON_FILE);
|
|
254
|
-
for (const f of fs.readdirSync(dir)) {
|
|
255
|
-
const m = f.match(CRON_FILE_RE);
|
|
256
|
-
if (!m) continue;
|
|
257
|
-
const pid = +m[1];
|
|
258
|
-
const isMe = f === path.basename(CRON_FILE);
|
|
259
|
-
let alive = isMe;
|
|
260
|
-
if (!isMe) { try { process.kill(pid, 0); alive = true; } catch {} }
|
|
261
|
-
if (!alive) continue;
|
|
262
|
-
const cronTasks: CronTask[] = isMe
|
|
263
|
-
? [...state.tasks.values()]
|
|
264
|
-
: (() => { try { return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8")); } catch { return []; } })();
|
|
265
|
-
if (cronTasks.length === 0) continue;
|
|
266
|
-
const label = isMe ? `PID ${pid} (this session)` : `PID ${pid}`;
|
|
267
|
-
const lines = cronTasks.map(t => {
|
|
268
|
-
const last = t.lastRun ? new Date(t.lastRun).toLocaleTimeString() : "never";
|
|
269
|
-
return ` #${t.id} | ${formatSchedule(t)} | ${t.description} | last run: ${last}`;
|
|
270
|
-
});
|
|
271
|
-
sections.push(`**${label}:**\n${lines.join("\n")}`);
|
|
272
|
-
}
|
|
273
|
-
} catch (e: any) { log.debug("list-all scan failed:", e?.message || e); }
|
|
274
|
-
if (sections.length === 0) {
|
|
275
|
-
return { content: [{ type: "text", text: "No scheduled tasks in any session." }] };
|
|
276
|
-
}
|
|
277
|
-
return {
|
|
278
|
-
content: [{ type: "text", text: `All sessions:\n\n${sections.join("\n\n")}` }],
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
if (action === "remove") {
|
|
283
|
-
const id = params.id as number;
|
|
284
|
-
if (!id || !state.tasks.has(id)) {
|
|
285
|
-
return { content: [{ type: "text", text: `Task #${id || "?"} not found.` }] };
|
|
286
|
-
}
|
|
287
|
-
const task = state.tasks.get(id)!;
|
|
288
|
-
state.stopTask(id);
|
|
289
|
-
state.tasks.delete(id);
|
|
290
|
-
saveCrons([...state.tasks.values()]);
|
|
291
|
-
log.info("cron_remove", id);
|
|
292
|
-
state.updateCronStatus();
|
|
293
|
-
return {
|
|
294
|
-
content: [{ type: "text", text: `Cron #${id} removed: ${task.description}` }],
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
return { content: [{ type: "text", text: `Unknown action: ${action}` }] };
|
|
299
|
-
},
|
|
300
|
-
});
|
|
50
|
+
export function getCronRemoveAutocompleteItems() { return cronRemoveAutocompleteItems(currentCronTasks()); }
|
|
51
|
+
export function parseCronScope(args: string[]): { scope: CronScope; rest: string[]; error?: string } {
|
|
52
|
+
let scope: CronScope = "session"; const rest: string[] = [];
|
|
53
|
+
for (const arg of args) {
|
|
54
|
+
if (arg === "--persist") { scope = "project"; continue; }
|
|
55
|
+
if (arg === "--scope" || arg === "--project") return { scope, rest, error: `"${arg}" is not supported; use --persist for a persistent cron` };
|
|
56
|
+
rest.push(arg);
|
|
57
|
+
}
|
|
58
|
+
return { scope, rest };
|
|
301
59
|
}
|
|
302
|
-
|
|
303
|
-
function
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
);
|
|
60
|
+
function projectStore(cwd: string) { return path.join(cwd, ".pi", "cron", "crons.json"); }
|
|
61
|
+
function validateSchedule(details: any) {
|
|
62
|
+
const interval = details.interval_seconds;
|
|
63
|
+
const hour = details.at_hour;
|
|
64
|
+
const minute = details.at_minute;
|
|
65
|
+
if (interval !== undefined && (!Number.isFinite(interval) || interval < 10)) throw new Error("interval_seconds must be finite and at least 10 seconds");
|
|
66
|
+
if (hour !== undefined && (!Number.isInteger(hour) || hour < 0 || hour > 23)) throw new Error("at_hour must be an integer from 0 to 23");
|
|
67
|
+
if (minute !== undefined && (!Number.isInteger(minute) || minute < 0 || minute > 59)) throw new Error("at_minute must be an integer from 0 to 59");
|
|
68
|
+
if (minute !== undefined && hour === undefined) throw new Error("at_minute requires at_hour");
|
|
312
69
|
}
|
|
70
|
+
function nextDelay(task: CronTask) { if (task.intervalMs) return task.intervalMs; const target = new Date(); target.setHours(task.atHour!, task.atMinute!, 0, 0); if (+target <= Date.now()) target.setDate(target.getDate() + 1); return +target - Date.now(); }
|
|
313
71
|
|
|
314
|
-
function
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
process.kill(pid, 0);
|
|
329
|
-
alive = true;
|
|
330
|
-
} catch {
|
|
331
|
-
/* dead */
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
if (!alive) continue;
|
|
335
|
-
const filePath = path.join(dir, f);
|
|
336
|
-
const cronTasks: CronTask[] = isMe
|
|
337
|
-
? [...state.tasks.values()]
|
|
338
|
-
: (() => {
|
|
339
|
-
try {
|
|
340
|
-
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
341
|
-
if (!Array.isArray(parsed)) return [];
|
|
342
|
-
return parsed;
|
|
343
|
-
} catch {
|
|
344
|
-
return [];
|
|
345
|
-
}
|
|
346
|
-
})();
|
|
347
|
-
if (cronTasks.length === 0) continue;
|
|
348
|
-
const label = isMe ? `PID ${pid} (this session)` : `PID ${pid}`;
|
|
349
|
-
for (const t of cronTasks) {
|
|
350
|
-
views.push(
|
|
351
|
-
toCronStatusTaskView(t, {
|
|
352
|
-
overlayId: `${f}:${t.id}`,
|
|
353
|
-
sessionLabel: label,
|
|
354
|
-
isLocal: isMe,
|
|
355
|
-
cronFile: filePath,
|
|
356
|
-
}),
|
|
357
|
-
);
|
|
358
|
-
}
|
|
72
|
+
export function registerCron(pi: ExtensionAPI, spawnAsyncAgent: any): { getCronTasks: () => CronTask[] } {
|
|
73
|
+
if (process.env.PI_SUBAGENT_CHILD === "1") return { getCronTasks: () => [] };
|
|
74
|
+
const tasks = new Map<string, CronTask>(); const timers = new Map<string, Timer>();
|
|
75
|
+
currentCronTasks = () => tasks.values();
|
|
76
|
+
const owned = new Map<string, CronLockOwner>(); const instanceId = randomUUID(); let ctx: any; let watchers: fs.FSWatcher[] = []; let health: ReturnType<typeof setInterval> | undefined;
|
|
77
|
+
function cleanupRuntime(releaseLocks = true) {
|
|
78
|
+
log.debug("cron_cleanup_runtime", { timers: timers.size, watchers: watchers.length, locks: owned.size, releaseLocks });
|
|
79
|
+
if (health) clearInterval(health); health = undefined;
|
|
80
|
+
for (const id of [...timers.keys()]) stop(id);
|
|
81
|
+
for (const watcher of watchers) try { watcher.close(); } catch (error: any) { log.warn("cron_watcher_close_failed", { code: error?.code }); }
|
|
82
|
+
watchers = [];
|
|
83
|
+
if (releaseLocks) for (const [file, owner] of owned) {
|
|
84
|
+
releaseLeaderLock(file, owner);
|
|
85
|
+
owned.delete(file);
|
|
359
86
|
}
|
|
360
|
-
|
|
361
|
-
log.debug("list-all scan failed:", e?.message || e);
|
|
87
|
+
else owned.clear();
|
|
362
88
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const fromAll = listAllCronViews(state).find((v) => v.id === overlayId);
|
|
368
|
-
const fromLocal = listLocalCronViews(state).find((v) => v.id === overlayId);
|
|
369
|
-
const view = fromAll || fromLocal;
|
|
370
|
-
if (!view) return false;
|
|
371
|
-
|
|
372
|
-
if (view.isLocal || (view.cronFile && view.cronFile === CRON_FILE)) {
|
|
373
|
-
if (!state.tasks.has(view.taskId)) return false;
|
|
374
|
-
state.stopTask(view.taskId);
|
|
375
|
-
state.tasks.delete(view.taskId);
|
|
376
|
-
saveCrons([...state.tasks.values()]);
|
|
377
|
-
state.updateCronStatus();
|
|
378
|
-
return true;
|
|
89
|
+
const stores = () => ctx ? [{ scope: "project" as const, file: projectStore(ctx.cwd) }] : [];
|
|
90
|
+
function updateStatus() {
|
|
91
|
+
try { if (ctx?.hasUI) setSlot("crons", ctx.ui.theme.fg("muted", `⏰ ${tasks.size} cron${tasks.size === 1 ? "" : "s"}`), ctx); } catch {}
|
|
92
|
+
pi.events.emit("pidash:cron-status", { count: tasks.size, tasks: [...tasks.values()].map(t => ({ id: qualifyCronId(t), scope: t.scope, description: t.description, schedule: formatCronSchedule(t), lastRun: t.lastRun, nextRun: t.nextRun, leader: t.leader })) });
|
|
379
93
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
if (!Array.isArray(raw)) return false;
|
|
386
|
-
const next = raw.filter((t: CronTask) => t.id !== view.taskId);
|
|
387
|
-
if (next.length === raw.length) return false;
|
|
388
|
-
fs.writeFileSync(view.cronFile, JSON.stringify(next), { mode: 0o600 });
|
|
389
|
-
return true;
|
|
390
|
-
} catch (e: any) {
|
|
391
|
-
log.debug("remote remove failed:", e?.message || e);
|
|
392
|
-
return false;
|
|
94
|
+
function persist(task: CronTask) {
|
|
95
|
+
try {
|
|
96
|
+
if (task.scope === "session") { if (sessionStore) writeSessionTasks(sessionStore, [...tasks.values()].filter(t => t.scope === "session")); return; }
|
|
97
|
+
mutateDurableCronStore(projectStore(task.cwd), old => old.map(t => t.id === task.id ? task as DurableCronTask : t));
|
|
98
|
+
} catch (error: any) { log.error("cron_persist_failed", qualifyCronId(task), error?.message || error); }
|
|
393
99
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
function registerCronCommand(pi: ExtensionAPI, state: CronInternals): void {
|
|
397
|
-
pi.registerCommand("cron", {
|
|
398
|
-
description: "Schedule recurring tasks — /cron list|list-all|remove <id>|<natural language>",
|
|
399
|
-
handler: async (args, ctx) => {
|
|
400
|
-
state.lastCtx = ctx;
|
|
401
|
-
state.lastCwd = ctx.cwd;
|
|
402
|
-
const text = (args || "").trim();
|
|
403
|
-
const parts = text.split(/\s+/);
|
|
404
|
-
const sub = parts[0]?.toLowerCase();
|
|
405
|
-
|
|
406
|
-
// Direct handlers — no AI needed
|
|
407
|
-
if (sub === "list" && parts.length === 1) {
|
|
408
|
-
if (!ctx.hasUI) return;
|
|
409
|
-
await openCronStatusOverlay(ctx, {
|
|
410
|
-
listTasks: () => listLocalCronViews(state),
|
|
411
|
-
removeTask: (id) => removeCronByOverlayId(state, id),
|
|
412
|
-
});
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
if (sub === "list-all") {
|
|
417
|
-
if (!ctx.hasUI) return;
|
|
418
|
-
await openCronStatusOverlay(ctx, {
|
|
419
|
-
title: "Cron tasks (all sessions)",
|
|
420
|
-
emptyMessage: "No crons in any session.",
|
|
421
|
-
borderTitle: (tasks) => {
|
|
422
|
-
const sessions = new Set(
|
|
423
|
-
tasks.map((t) => t.sessionLabel || "?").filter(Boolean),
|
|
424
|
-
);
|
|
425
|
-
return `all sessions · ${tasks.length} tasks · ${sessions.size} session${sessions.size === 1 ? "" : "s"}`;
|
|
426
|
-
},
|
|
427
|
-
listTasks: () => listAllCronViews(state),
|
|
428
|
-
removeTask: (id) => removeCronByOverlayId(state, id),
|
|
429
|
-
});
|
|
430
|
-
return;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
if ((sub === "remove" || sub === "rm" || sub === "delete" || sub === "kill") && parts.length > 1) {
|
|
434
|
-
const ids = parts.slice(1).map(p => parseInt(p, 10)).filter(n => !isNaN(n));
|
|
435
|
-
if (ids.length === 0) {
|
|
436
|
-
if (ctx.hasUI) ctx.ui.notify("No valid task IDs. Use /cron list", "warning");
|
|
437
|
-
return;
|
|
438
|
-
}
|
|
439
|
-
const removed: string[] = [];
|
|
440
|
-
const notFound: string[] = [];
|
|
441
|
-
for (const id of ids) {
|
|
442
|
-
if (state.tasks.has(id)) {
|
|
443
|
-
const task = state.tasks.get(id)!;
|
|
444
|
-
state.stopTask(id);
|
|
445
|
-
state.tasks.delete(id);
|
|
446
|
-
removed.push(`#${id} (${task.description})`);
|
|
447
|
-
} else {
|
|
448
|
-
notFound.push(`#${id}`);
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
saveCrons([...state.tasks.values()]);
|
|
452
|
-
state.updateCronStatus();
|
|
453
|
-
const lines: string[] = [];
|
|
454
|
-
if (removed.length) lines.push(`Removed: ${removed.join(", ")}`);
|
|
455
|
-
if (notFound.length) lines.push(`Not found: ${notFound.join(", ")}`);
|
|
456
|
-
if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), removed.length ? "info" : "warning");
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
if (!text) {
|
|
461
|
-
if (ctx.hasUI) ctx.ui.notify("Usage:\n/cron <natural language task>\n/cron list\n/cron list-all\n/cron remove <id>", "info");
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
// Only "add" goes through the AI for natural language parsing
|
|
466
|
-
pi.sendUserMessage(
|
|
467
|
-
`The user wants to schedule a cron task. Parse their request and use the cron_manage tool with action "add".\n\nUser request: "${text}"\n\nInterpret the schedule and task from the natural language. For interval-based schedules, convert to seconds. For time-based schedules, extract hour and minute.`,
|
|
468
|
-
{ deliverAs: "followUp" },
|
|
469
|
-
);
|
|
470
|
-
},
|
|
471
|
-
});
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
// ── Registration ─────────────────────────────────────────────────────
|
|
475
|
-
|
|
476
|
-
export function registerCron(
|
|
477
|
-
pi: ExtensionAPI,
|
|
478
|
-
spawnAsyncAgent: (
|
|
479
|
-
agentName: string,
|
|
480
|
-
task: string,
|
|
481
|
-
cwd: string,
|
|
482
|
-
agents: any[],
|
|
483
|
-
options?: {
|
|
484
|
-
fireAndForget?: boolean;
|
|
485
|
-
name?: string;
|
|
486
|
-
parentModelId?: string;
|
|
487
|
-
parentProvider?: string;
|
|
488
|
-
},
|
|
489
|
-
) => { id: string; error?: string },
|
|
490
|
-
): { getCronTasks: () => CronTask[] } {
|
|
491
|
-
if (process.env.PI_SUBAGENT_CHILD === "1") return { getCronTasks: () => [] };
|
|
492
|
-
|
|
493
|
-
const tasks = new Map<number, CronTask>();
|
|
494
|
-
const timers = new Map<number, ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>>();
|
|
495
|
-
|
|
496
|
-
const state: CronInternals = {
|
|
497
|
-
tasks,
|
|
498
|
-
nextId: 1,
|
|
499
|
-
lastCwd: "",
|
|
500
|
-
lastCtx: null,
|
|
501
|
-
startTask,
|
|
502
|
-
stopTask,
|
|
503
|
-
updateCronStatus,
|
|
504
|
-
};
|
|
505
|
-
|
|
506
|
-
function updateCronStatus() {
|
|
507
|
-
const count = tasks.size;
|
|
508
|
-
saveCrons([...tasks.values()]);
|
|
100
|
+
async function execute(task: CronTask) {
|
|
509
101
|
try {
|
|
510
|
-
if (
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
tasks: [...tasks.values()].map(t => ({
|
|
521
|
-
id: t.id,
|
|
522
|
-
description: t.description,
|
|
523
|
-
schedule: formatSchedule(t),
|
|
524
|
-
lastRun: t.lastRun,
|
|
525
|
-
nextRun: t.nextRun,
|
|
526
|
-
})),
|
|
527
|
-
});
|
|
102
|
+
if (durable(task.scope) && !task.leader) return;
|
|
103
|
+
task.lastRun = Date.now(); persist(task); updateStatus(); log.info("cron_execute", qualifyCronId(task));
|
|
104
|
+
const cmd = task.task.trim();
|
|
105
|
+
if (cmd.startsWith("/")) { pi.sendUserMessage(cmd, { deliverAs: "followUp" }); return; }
|
|
106
|
+
const dispatch = decideAsyncLlmDispatch({ parentProvider: ctx?.model?.provider, cwd: task.cwd, mustAsync: true });
|
|
107
|
+
if (dispatch.action === "skip") { log.error("cron_error", qualifyCronId(task), dispatch.note); return; }
|
|
108
|
+
const { discoverAgents } = await import("./agents.js");
|
|
109
|
+
const { agents } = discoverAgents(task.cwd, "user");
|
|
110
|
+
spawnAsyncAgent("worker", cmd, task.cwd, agents, { name: `Cron: ${task.description.slice(0, 40)}`, ...(dispatch.action === "sidecar-async" ? { parentProvider: dispatch.sidecar.provider, parentModelId: dispatch.sidecar.model } : {}) });
|
|
111
|
+
} catch (error: any) { log.error("cron_execute_failed", qualifyCronId(task), error?.message || error); }
|
|
528
112
|
}
|
|
529
|
-
|
|
530
|
-
function
|
|
531
|
-
|
|
532
|
-
task.
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
// Slash command — send as user message
|
|
539
|
-
pi.sendUserMessage(cmd, { deliverAs: "followUp" });
|
|
540
|
-
} else {
|
|
541
|
-
// Prompt task — run as async agent, result surfaces to AI
|
|
542
|
-
const cwd = state.lastCwd || process.cwd();
|
|
543
|
-
const parentProvider = state.lastCtx?.model?.provider as string | undefined;
|
|
544
|
-
const dispatch = decideAsyncLlmDispatch({
|
|
545
|
-
parentProvider,
|
|
546
|
-
cwd,
|
|
547
|
-
mustAsync: true,
|
|
548
|
-
});
|
|
549
|
-
if (dispatch.action === "skip") {
|
|
550
|
-
log.error("cron_error", task.id, dispatch.note);
|
|
551
|
-
try {
|
|
552
|
-
state.lastCtx?.ui?.notify?.(
|
|
553
|
-
`Cron #${task.id} skipped: set internal_operations_provider/internal_operations_model for acpx`,
|
|
554
|
-
"warning",
|
|
555
|
-
);
|
|
556
|
-
} catch { /* stale UI */ }
|
|
557
|
-
return;
|
|
558
|
-
}
|
|
559
|
-
const { agents } = discoverAgents(cwd, "user");
|
|
560
|
-
const sidecarOpts =
|
|
561
|
-
dispatch.action === "sidecar-async"
|
|
562
|
-
? {
|
|
563
|
-
parentProvider: dispatch.sidecar.provider,
|
|
564
|
-
parentModelId: dispatch.sidecar.model,
|
|
565
|
-
}
|
|
566
|
-
: {};
|
|
567
|
-
spawnAsyncAgent("worker", cmd, cwd, agents, {
|
|
568
|
-
name: `Cron: ${task.description.slice(0, 40)}`,
|
|
569
|
-
...sidecarOpts,
|
|
570
|
-
});
|
|
571
|
-
}
|
|
113
|
+
function stop(id: string) { const timer = timers.get(id); if (timer) { clearTimeout(timer as any); clearInterval(timer as any); timers.delete(id); } }
|
|
114
|
+
function start(task: CronTask) {
|
|
115
|
+
stop(task.id); if (durable(task.scope) && !task.leader) return;
|
|
116
|
+
const schedule = (runAt = task.nextRun ?? Date.now() + nextDelay(task)) => {
|
|
117
|
+
const delay = Math.max(0, runAt - Date.now());
|
|
118
|
+
const timer = setTimeout(async () => { try { await execute(task); schedule(Date.now() + nextDelay(task)); } catch (error: any) { log.error("cron_timer_failed", qualifyCronId(task), error?.message || error); } }, delay);
|
|
119
|
+
task.nextRun = runAt; persist(task); updateStatus(); timer.unref?.(); timers.set(task.id, timer);
|
|
120
|
+
};
|
|
121
|
+
schedule();
|
|
572
122
|
}
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
if ((timer as any).unref) (timer as any).unref();
|
|
581
|
-
timers.set(task.id, timer);
|
|
582
|
-
} else if (task.atHour !== undefined && task.atMinute !== undefined) {
|
|
583
|
-
const scheduleNext = () => {
|
|
584
|
-
const ms = msUntilNextTime(task.atHour!, task.atMinute!);
|
|
585
|
-
task.nextRun = Date.now() + ms;
|
|
586
|
-
saveCrons([...tasks.values()]);
|
|
587
|
-
const timer = setTimeout(() => {
|
|
588
|
-
executeCronTask(task);
|
|
589
|
-
scheduleNext();
|
|
590
|
-
}, ms);
|
|
591
|
-
if (timer.unref) timer.unref();
|
|
592
|
-
timers.set(task.id, timer);
|
|
593
|
-
};
|
|
594
|
-
scheduleNext();
|
|
123
|
+
function syncStore(scope: "project", file: string) {
|
|
124
|
+
const leader = owned.has(file); const disk = readDurableCronStore(file).tasks;
|
|
125
|
+
for (const old of [...tasks.values()]) if (old.scope === scope && !disk.some(t => t.id === old.id)) { stop(old.id); tasks.delete(old.id); }
|
|
126
|
+
for (const raw of disk) {
|
|
127
|
+
try { validateDurableCronTask(raw); } catch (error: any) { log.error("invalid durable cron ignored", raw && typeof raw === "object" ? (raw as any).id : "unknown", error?.message || error); continue; }
|
|
128
|
+
const task: CronTask = { ...raw, leader }; const existing = tasks.get(task.id);
|
|
129
|
+
if (!existing) { tasks.set(task.id, task); start(task); } else { const changed = existing.intervalMs !== task.intervalMs || existing.atHour !== task.atHour || existing.atMinute !== task.atMinute || existing.leader !== leader; Object.assign(existing, task); if (changed) start(existing); }
|
|
595
130
|
}
|
|
596
131
|
}
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
clearInterval(timer as any);
|
|
603
|
-
timers.delete(id);
|
|
132
|
+
function election() {
|
|
133
|
+
for (const { scope, file } of stores()) {
|
|
134
|
+
let owner = owned.get(file); if (owner && !refreshLeaderLock(file, owner)) { owned.delete(file); owner = undefined; }
|
|
135
|
+
if (!owner) { owner = acquireLeaderLock(file, instanceId) || undefined; if (owner) { owned.set(file, owner); log.info("cron_leader_acquired", scope); } }
|
|
136
|
+
syncStore(scope, file);
|
|
604
137
|
}
|
|
138
|
+
updateStatus();
|
|
605
139
|
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
for (const task of onDisk) {
|
|
621
|
-
// Validate before starting — same rules as add path
|
|
622
|
-
if (!task.task || typeof task.task !== "string" || !task.task.trim()) continue;
|
|
623
|
-
if (task.intervalMs !== undefined && task.intervalMs < 10000) continue;
|
|
624
|
-
|
|
625
|
-
const existing = tasks.get(task.id);
|
|
626
|
-
if (!existing) {
|
|
627
|
-
if (task.id >= state.nextId) state.nextId = task.id + 1;
|
|
628
|
-
tasks.set(task.id, task);
|
|
629
|
-
startTask(task);
|
|
630
|
-
continue;
|
|
631
|
-
}
|
|
632
|
-
// Refresh mutable fields from disk without restarting timer unless schedule changed
|
|
633
|
-
const scheduleChanged =
|
|
634
|
-
existing.intervalMs !== task.intervalMs ||
|
|
635
|
-
existing.atHour !== task.atHour ||
|
|
636
|
-
existing.atMinute !== task.atMinute;
|
|
637
|
-
Object.assign(existing, task);
|
|
638
|
-
if (scheduleChanged) startTask(existing);
|
|
639
|
-
}
|
|
640
|
-
updateCronStatus();
|
|
140
|
+
function add(scope: CronScope, details: any) {
|
|
141
|
+
if (scope !== "session" && scope !== "project") throw new Error("scope must be session or project");
|
|
142
|
+
if (durable(scope) && !durableCronSupported()) throw new Error("Persistent cron scheduling is unavailable on this platform");
|
|
143
|
+
validateSchedule(details);
|
|
144
|
+
const task: CronTask = { id: randomUUID(), scope, cwd: ctx?.cwd || process.cwd(), description: details.description || details.task.slice(0, 60), task: details.task, intervalMs: details.interval_seconds !== undefined ? details.interval_seconds * 1000 : undefined, atHour: details.at_hour, atMinute: details.at_minute ?? (details.at_hour !== undefined ? 0 : undefined), createdAt: Date.now(), leader: scope === "session" };
|
|
145
|
+
if (!task.task || (!task.intervalMs && task.atHour === undefined)) throw new Error("task and a schedule are required");
|
|
146
|
+
if (durable(scope)) mutateDurableCronStore(projectStore(task.cwd), old => [...old, task as DurableCronTask]);
|
|
147
|
+
tasks.set(task.id, task);
|
|
148
|
+
// We may already own the relevant lock; schedule synchronously rather than waiting for fs.watch.
|
|
149
|
+
if (scope === "session" || owned.has(projectStore(task.cwd))) { task.leader = true; start(task); }
|
|
150
|
+
else election();
|
|
151
|
+
if (scope === "session") persist(task);
|
|
152
|
+
updateStatus(); log.info("cron_created", { scope, id: qualifyCronId(task) }); return task;
|
|
641
153
|
}
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
const
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
if (task.id >= state.nextId) state.nextId = task.id + 1;
|
|
659
|
-
tasks.set(task.id, task);
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
// Restart all timers with fresh context
|
|
663
|
-
for (const task of tasks.values()) {
|
|
664
|
-
startTask(task);
|
|
665
|
-
}
|
|
666
|
-
updateCronStatus();
|
|
667
|
-
|
|
668
|
-
// Ensure file exists so we can watch peer list-all removes
|
|
669
|
-
if (!fs.existsSync(CRON_FILE)) saveCrons([...tasks.values()]);
|
|
670
|
-
|
|
671
|
-
// Watch own file so /cron list-all remove from another session stops our timers
|
|
672
|
-
try {
|
|
673
|
-
cronFileWatcher?.close();
|
|
674
|
-
} catch { /* ignore */ }
|
|
675
|
-
cronFileWatcher = null;
|
|
676
|
-
try {
|
|
677
|
-
let debounce: ReturnType<typeof setTimeout> | null = null;
|
|
678
|
-
cronFileWatcher = fs.watch(CRON_FILE, () => {
|
|
679
|
-
if (Date.now() < CRON_IGNORE_WATCH_UNTIL) return;
|
|
680
|
-
if (debounce) clearTimeout(debounce);
|
|
681
|
-
debounce = setTimeout(() => {
|
|
682
|
-
if (Date.now() < CRON_IGNORE_WATCH_UNTIL) return;
|
|
683
|
-
try {
|
|
684
|
-
syncTasksFromDisk();
|
|
685
|
-
} catch (e: any) {
|
|
686
|
-
log.debug("sync from disk failed:", e?.message || e);
|
|
687
|
-
}
|
|
688
|
-
}, 150);
|
|
689
|
-
});
|
|
690
|
-
cronFileWatcher.unref?.();
|
|
691
|
-
} catch (e: any) {
|
|
692
|
-
log.debug("watch setup failed:", e?.message || e);
|
|
154
|
+
function remove(qualified: string) {
|
|
155
|
+
const [kind, id] = qualified.split(":", 2); const scope = kind === "persist" ? "project" : kind as CronScope; if (!id || !["session", "project"].includes(scope)) return false;
|
|
156
|
+
const task = tasks.get(id); if (scope === "session") { if (!task) return false; stop(id); tasks.delete(id); if (sessionStore) writeSessionTasks(sessionStore, [...tasks.values()].filter(t => t.scope === "session")); } else { const file = projectStore(ctx.cwd); let found = false; mutateDurableCronStore(file, old => { found = old.some(t => t.id === id); return old.filter(t => t.id !== id); }); if (!found) return false; if (task) { stop(id); tasks.delete(id); } }
|
|
157
|
+
updateStatus(); return true;
|
|
158
|
+
}
|
|
159
|
+
pi.registerTool({ name: "cron_manage", description: "Manage scheduled tasks. Set persist=true to keep a task across Pi sessions in this project.", parameters: Type.Object({ action: Type.Union([Type.Literal("add"), Type.Literal("list"), Type.Literal("remove")]), persist: Type.Optional(Type.Boolean()), description: Type.Optional(Type.String()), task: Type.Optional(Type.String()), interval_seconds: Type.Optional(Type.Number()), at_hour: Type.Optional(Type.Number()), at_minute: Type.Optional(Type.Number()), id: Type.Optional(Type.String()) }), async execute(_id, params: any) {
|
|
160
|
+
const scope: CronScope = params.persist ? "project" : "session";
|
|
161
|
+
try { if (params.action === "add" && (ctx?.mode === "print" || ctx?.mode === "json")) return { content: [{ type: "text", text: "Error: Cron scheduling is unavailable in one-shot mode." }] }; if (params.action === "add") { const task = add(scope, params); return { content: [{ type: "text", text: `Cron ${qualifyCronId(task)} created: ${formatCronSchedule(task)} → ${task.description}` }] }; } if (params.action === "remove") return { content: [{ type: "text", text: remove(params.id || "") ? "Cron removed." : "Task not found." }] }; const selected = [...tasks.values()].filter(t => !params.persist || t.scope === "project"); return { content: [{ type: "text", text: selected.length ? selected.map(t => `${qualifyCronId(t)} | ${formatCronSchedule(t)} | ${t.description} | ${t.leader === false ? "waiting for leader" : "active"}`).join("\n") : "No scheduled tasks." }] }; } catch (error: any) { return { content: [{ type: "text", text: `Error: ${error.message}` }] }; }
|
|
162
|
+
}, } as any);
|
|
163
|
+
pi.registerCommand("cron", { description: "Schedule tasks: add, list, list-all, remove; use --persist to keep a cron", handler: async (args, commandCtx) => { ctx = commandCtx; const parsed = parseCronScope((args || "").trim().split(/\s+/).filter(Boolean)); if (parsed.error) { ctx.ui?.notify(parsed.error, "warning"); return; } const [sub, ...rest] = parsed.rest; if (sub === "list" || sub === "list-all") { const showAll = sub === "list-all"; if (ctx.hasUI) await openCronStatusOverlay(ctx, { title: showAll ? "All cron tasks" : "Cron tasks", listTasks: () => [...tasks.values()].filter(t => showAll || !args.includes("--persist") || t.scope === "project").map(t => toCronStatusTaskView(t, { overlayId: qualifyCronId(t), isLocal: t.scope === "session", sessionLabel: t.leader === false ? `${t.scope} (waiting for leader)` : t.scope })), removeTask: remove }); return; } if (["remove", "rm", "delete"].includes(sub) && rest[0]) { ctx.ui?.notify(remove(rest[0]) ? "Cron removed." : "Task not found.", "info"); return; } pi.sendUserMessage(`Schedule this cron request using cron_manage. Set persist to ${parsed.scope === "project"}. Use persistence only when the user requests it. User request: "${parsed.rest.join(" ")}"`, { deliverAs: "followUp" }); }});
|
|
164
|
+
pi.events.on("pidash:cron-kill", (target: unknown) => {
|
|
165
|
+
if (target !== "all") { if (typeof target === "string") remove(target); return; }
|
|
166
|
+
const byStore = new Map<string, Set<string>>();
|
|
167
|
+
for (const task of tasks.values()) if (durable(task.scope)) {
|
|
168
|
+
const file = projectStore(task.cwd);
|
|
169
|
+
const ids = byStore.get(file) || new Set<string>(); ids.add(task.id); byStore.set(file, ids);
|
|
693
170
|
}
|
|
171
|
+
for (const [file, ids] of byStore) mutateDurableCronStore(file, old => old.filter(task => !ids.has(task.id)));
|
|
172
|
+
for (const id of [...timers.keys()]) stop(id);
|
|
173
|
+
tasks.clear();
|
|
174
|
+
if (sessionStore) writeSessionTasks(sessionStore, []);
|
|
175
|
+
updateStatus();
|
|
694
176
|
});
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
177
|
+
pi.on("session_start", (_event, newCtx) => {
|
|
178
|
+
cleanupRuntime(); tasks.clear(); ctx = newCtx; sessionStore = sessionStoreFor(ctx);
|
|
179
|
+
if (ctx.mode === "print" || ctx.mode === "json") return;
|
|
180
|
+
for (const raw of readSessionTasks(sessionStore)) {
|
|
181
|
+
try { validateSessionCronTask(raw); tasks.set(raw.id, { ...raw, scope: "session", cwd: ctx.cwd, leader: true }); }
|
|
182
|
+
catch (error: any) { log.error("invalid session cron ignored", error?.message || error); }
|
|
183
|
+
}
|
|
184
|
+
const projectCronCount = readDurableCronStore(projectStore(ctx.cwd)).tasks.length;
|
|
185
|
+
const trusted = typeof ctx.isProjectTrusted !== "function" || ctx.isProjectTrusted() === true;
|
|
186
|
+
for (const task of [...tasks.values()]) start(task);
|
|
187
|
+
if (trusted) {
|
|
188
|
+
for (const store of stores()) { fs.mkdirSync(path.dirname(store.file), { recursive: true, mode: 0o700 }); try { const w = fs.watch(path.dirname(store.file), () => election()); w.unref?.(); watchers.push(w); } catch (e: any) { log.warn("cron_watch_failed", { code: e?.code }); } }
|
|
189
|
+
election();
|
|
190
|
+
health = setInterval(() => { try { election(); } catch (error: any) { log.error("cron_election_failed", error?.message || error); } }, 10_000); health.unref?.();
|
|
191
|
+
} else syncStore("project", projectStore(ctx.cwd));
|
|
192
|
+
if (projectCronCount > 0 && ctx.hasUI) {
|
|
193
|
+
const leader = trusted && owned.has(projectStore(ctx.cwd));
|
|
194
|
+
ctx.ui.notify(`Loaded ${projectCronCount} project cron${projectCronCount === 1 ? "" : "s"} (${leader ? "executing" : trusted ? "waiting for leader" : "untrusted"}).`, "info");
|
|
195
|
+
log.info("project_crons_loaded", { count: projectCronCount, leader, trusted });
|
|
710
196
|
}
|
|
711
|
-
saveCrons([...tasks.values()]);
|
|
712
|
-
updateCronStatus();
|
|
713
197
|
});
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
cronFileWatcher?.close();
|
|
719
|
-
} catch { /* ignore */ }
|
|
720
|
-
cronFileWatcher = null;
|
|
721
|
-
for (const id of timers.keys()) {
|
|
722
|
-
stopTask(id);
|
|
198
|
+
pi.on("session_shutdown", (event: any) => {
|
|
199
|
+
cleanupRuntime();
|
|
200
|
+
if (["new", "fork", "quit"].includes(event?.reason) && sessionStore) {
|
|
201
|
+
try { fs.unlinkSync(sessionStore); } catch (error: any) { if (error?.code !== "ENOENT") log.warn("cron session cleanup failed", error?.message || error); }
|
|
723
202
|
}
|
|
724
|
-
saveCrons([...tasks.values()]);
|
|
725
203
|
});
|
|
726
|
-
|
|
727
|
-
// Register cron_manage tool — the AI calls this with structured params
|
|
728
|
-
registerCronTool(pi, state);
|
|
729
|
-
|
|
730
|
-
// /cron command
|
|
731
|
-
registerCronCommand(pi, state);
|
|
732
|
-
|
|
733
|
-
return {
|
|
734
|
-
getCronTasks: () => [...tasks.values()],
|
|
735
|
-
};
|
|
204
|
+
return { getCronTasks: () => [...tasks.values()] };
|
|
736
205
|
}
|