pi-orchestrator-config 4.3.11 → 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.
@@ -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 { getProjectTmpDir, parseProcStartTime } from "./utils.js";
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
- /** Unique session suffix — prevents PID collisions across containers */
61
- /** Suffix must be unique across containers but stable across reloads (same process).
62
- * Use process start time from /proc or process.uptime() as a stable identifier. */
63
- const SESSION_SUFFIX = (() => {
64
- try {
65
- const stat = fs.readFileSync("/proc/self/stat", "utf-8");
66
- const startTime = parseProcStartTime(stat);
67
- if (!startTime) throw new Error("failed to parse start time");
68
- return `${process.pid}-${startTime}`;
69
- } catch {
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
- function loadCrons(): CronTask[] {
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
- function cleanupOrphanedCronFiles(): void {
104
- try {
105
- const dir = path.dirname(CRON_FILE);
106
- const myFile = path.basename(CRON_FILE);
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
- // ── Helpers ──────────────────────────────────────────────────────────
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
- function msUntilNextTime(hour: number, minute: number): number {
153
- const now = new Date();
154
- const target = new Date(now);
155
- target.setHours(hour, minute, 0, 0);
156
- if (target.getTime() <= now.getTime()) {
157
- target.setDate(target.getDate() + 1);
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 target.getTime() - now.getTime();
48
+ return items;
160
49
  }
161
-
162
- // ── Extracted registration functions ─────────────────────────────────
163
-
164
- function registerCronTool(pi: ExtensionAPI, state: CronInternals): void {
165
- pi.registerTool({
166
- name: "cron_manage",
167
- description: "Manage scheduled cron tasks. Use this tool when the user wants to schedule, list, or remove recurring tasks.",
168
- parameters: Type.Object({
169
- action: Type.Union([Type.Literal("add"), Type.Literal("list"), Type.Literal("list-all"), Type.Literal("remove")], {
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 listLocalCronViews(state: CronInternals) {
304
- const file = CRON_FILE || "";
305
- return [...state.tasks.values()].map((t) =>
306
- toCronStatusTaskView(t, {
307
- overlayId: String(t.id),
308
- isLocal: true,
309
- cronFile: file,
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 listAllCronViews(state: CronInternals) {
315
- const views: ReturnType<typeof toCronStatusTaskView>[] = [];
316
- if (!CRON_FILE) return views;
317
- try {
318
- const dir = path.dirname(CRON_FILE);
319
- const myBase = path.basename(CRON_FILE);
320
- for (const f of fs.readdirSync(dir)) {
321
- const m = f.match(CRON_FILE_RE);
322
- if (!m) continue;
323
- const pid = +m[1];
324
- const isMe = f === myBase;
325
- let alive = isMe;
326
- if (!isMe) {
327
- try {
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
- } catch (e: any) {
361
- log.debug("list-all scan failed:", e?.message || e);
87
+ else owned.clear();
362
88
  }
363
- return views;
364
- }
365
-
366
- function removeCronByOverlayId(state: CronInternals, overlayId: string): boolean {
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
- // Other session — edit their JSON; their watcher (if running new code) resyncs timers.
382
- if (!view.cronFile) return false;
383
- try {
384
- const raw = JSON.parse(fs.readFileSync(view.cronFile, "utf-8"));
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 (state.lastCtx?.hasUI) {
511
- if (count > 0) {
512
- setSlot("crons", state.lastCtx.ui.theme.fg("muted", `⏰ ${count} cron${count > 1 ? "s" : ""}`), state.lastCtx);
513
- } else {
514
- setSlot("crons", state.lastCtx.ui.theme.fg("muted", `⏰ 0 crons`), state.lastCtx);
515
- }
516
- }
517
- } catch { /* stale ctx after session replacement */ }
518
- pi.events.emit("pidash:cron-status", {
519
- count,
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 executeCronTask(task: CronTask) {
531
- log.info("cron_execute", task.id);
532
- task.lastRun = Date.now();
533
- saveCrons([...tasks.values()]);
534
-
535
- const cmd = task.task.trim();
536
-
537
- if (cmd.startsWith("/")) {
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
- function startTask(task: CronTask) {
575
- const existing = timers.get(task.id);
576
- if (existing) { clearTimeout(existing as any); clearInterval(existing as any); }
577
-
578
- if (task.intervalMs) {
579
- const timer = setInterval(() => executeCronTask(task), task.intervalMs);
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
- function stopTask(id: number) {
599
- const timer = timers.get(id);
600
- if (timer) {
601
- clearTimeout(timer as any);
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
- /** Resync in-memory tasks from CRON_FILE (external list-all remove / peer edit). */
608
- function syncTasksFromDisk(): void {
609
- if (!CRON_FILE) return;
610
- const onDisk = loadCrons();
611
- const diskIds = new Set(onDisk.map((t) => t.id));
612
-
613
- for (const id of [...tasks.keys()]) {
614
- if (!diskIds.has(id)) {
615
- stopTask(id);
616
- tasks.delete(id);
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
- let cronFileWatcher: fs.FSWatcher | null = null;
644
-
645
- // Restore persisted crons on session start
646
- pi.on("session_start", (_event, ctx) => {
647
- state.lastCwd = ctx.cwd;
648
- state.lastCtx = ctx;
649
- CRON_FILE = path.join(getProjectTmpDir(ctx.cwd), `cron-${SESSION_SUFFIX}.json`);
650
- cleanupOrphanedCronFiles();
651
- // Skip cron scheduling in one-shot modes
652
- if (ctx.mode === "print" || ctx.mode === "json") return;
653
-
654
- // Restore from persistence (after /reload or /new)
655
- const restored = loadCrons();
656
- for (const task of restored) {
657
- if (!tasks.has(task.id)) {
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
- // Handle cron kill from pidash browser
697
- pi.events.on("pidash:cron-kill", (data: unknown) => {
698
- const target = data as string;
699
- if (target === "all") {
700
- for (const id of [...tasks.keys()]) {
701
- stopTask(id);
702
- tasks.delete(id);
703
- }
704
- } else {
705
- const id = parseInt(target, 10);
706
- if (id && tasks.has(id)) {
707
- stopTask(id);
708
- tasks.delete(id);
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
- // Stop all timers and persist on shutdown
716
- pi.on("session_shutdown", () => {
717
- try {
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
  }