diff --git a/AGENTS.md b/AGENTS.md index 69a70b1d83..ef545c6ec8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,7 @@ app/api/ skills/install/route.ts POST install skills via npx skills add skills/search/route.ts POST skills.sh search subagents/settings/route.ts GET/PUT built-in subagent switch and maxConcurrent + scheduled-prompts/route.ts GET switch + active jobs | PUT { enabled } | DELETE { cwd, jobId } cancel (lib/scheduled-prompts.ts: the schedule_prompt tool, replaces pi-schedule-prompt; Settings › Scheduled prompts) web-auth/route.ts GET status | POST login | DELETE logout (browser password) provider-usage/query/route.ts POST provider usage quotas push/config/route.ts GET VAPID public key diff --git a/app/api/scheduled-prompts/route.ts b/app/api/scheduled-prompts/route.ts new file mode 100644 index 0000000000..90105279fb --- /dev/null +++ b/app/api/scheduled-prompts/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server"; +import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security"; +import { + cancelScheduledJob, + listActiveScheduledJobs, + stopAllScheduledPrompts, +} from "@/lib/scheduled-prompts"; +import { isScheduledPromptsEnabled, writeScheduledPromptsEnabled } from "@/lib/scheduled-prompts-settings"; + +export const dynamic = "force-dynamic"; + +function snapshot() { + const enabled = isScheduledPromptsEnabled(); + return { enabled, jobs: enabled ? listActiveScheduledJobs() : [] }; +} + +function failure(error: unknown) { + return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 }); +} + +export async function GET() { + try { + return NextResponse.json(snapshot()); + } catch (error) { + return failure(error); + } +} + +async function readBody(req: Request): Promise | NextResponse> { + if (!isApiRequestAllowed(req)) return NextResponse.json({ error: "Untrusted API request" }, { status: 403 }); + if (!hasJsonContentType(req)) return NextResponse.json({ error: "Content-Type must be application/json" }, { status: 415 }); + const body: unknown = await req.json().catch(() => null); + if (body === null || typeof body !== "object" || Array.isArray(body)) { + return NextResponse.json({ error: "Body must be a JSON object" }, { status: 400 }); + } + return body as Record; +} + +export async function PUT(req: Request) { + const body = await readBody(req); + if (body instanceof NextResponse) return body; + if (typeof body.enabled !== "boolean") { + return NextResponse.json({ error: "enabled must be a boolean" }, { status: 400 }); + } + try { + writeScheduledPromptsEnabled(body.enabled); + // Switching off ends every live timer now. Switching on needs a session reload to register the tool. + if (!body.enabled) stopAllScheduledPrompts(); + return NextResponse.json(snapshot()); + } catch (error) { + return failure(error); + } +} + +export async function DELETE(req: Request) { + const body = await readBody(req); + if (body instanceof NextResponse) return body; + if (typeof body.cwd !== "string" || !body.cwd || typeof body.jobId !== "string" || !body.jobId) { + return NextResponse.json({ error: "cwd and jobId are required" }, { status: 400 }); + } + // Only a job a live session is firing can be cancelled here, never an arbitrary path. + if (!listActiveScheduledJobs().some((job) => job.cwd === body.cwd && job.id === body.jobId)) { + return NextResponse.json({ error: "Scheduled prompt not found" }, { status: 404 }); + } + try { + cancelScheduledJob(body.cwd, body.jobId); + return NextResponse.json(snapshot()); + } catch (error) { + return failure(error); + } +} diff --git a/app/settings.css b/app/settings.css index 3820cf80c5..f21bfcf012 100644 --- a/app/settings.css +++ b/app/settings.css @@ -1468,13 +1468,12 @@ .settings-section-tab { position: relative; display: flex; - flex: 0 0 96px; + flex: 0 0 auto; align-items: center; justify-content: center; gap: 5px; - width: 96px; height: 100%; - padding: 0 2px; + padding: 0 14px; border: 0; outline: none; background: transparent; @@ -2625,3 +2624,74 @@ select.mcp-add-input { } } } + +.schedule-jobs { + display: flex; + flex: 1; + flex-direction: column; + gap: 10px; + min-height: 0; + padding: 14px 18px; + overflow-y: auto; +} + +.schedule-jobs-title { + color: var(--text); + font-size: 12px; +} + +.schedule-job-list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.schedule-job { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-panel); +} + +.schedule-job-main { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + font-size: 12px; +} + +.schedule-job-heading { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; + color: var(--text); +} + +.schedule-job-heading code { + color: var(--text-muted); + font-size: 11px; +} + +.schedule-job-prompt { + overflow: hidden; + color: var(--text-muted); + text-overflow: ellipsis; + white-space: nowrap; +} + +.schedule-job-meta { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + color: var(--text-dim); + font-size: 11px; +} diff --git a/components/ScheduledPromptsConfig.tsx b/components/ScheduledPromptsConfig.tsx new file mode 100644 index 0000000000..bc5805e492 --- /dev/null +++ b/components/ScheduledPromptsConfig.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useI18n } from "@/hooks/useI18n"; +import type { ScheduledPromptsResponse } from "@/lib/api-types"; +import { sendAgentCommand } from "@/lib/agent-client"; +import { shortenPath } from "@/lib/display-path"; +import { ConfigButton, ConfigEmptyState, ConfigPanelShell, ConfigSwitch } from "./SettingsUi"; + +interface Props { + embedded?: boolean; + sessionId?: string | null; + onClose: () => void; + onReloaded?: () => void; +} + +const REFRESH_MS = 5000; + +async function request(method: "GET" | "PUT" | "DELETE", body?: unknown): Promise { + const response = await fetch("/api/scheduled-prompts", { + method, + ...(body === undefined ? {} : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }), + }); + const data = await response.json() as Partial & { error?: string }; + if (!response.ok || data.error || typeof data.enabled !== "boolean" || !Array.isArray(data.jobs)) { + throw new Error(data.error ?? `HTTP ${response.status}`); + } + return { enabled: data.enabled, jobs: data.jobs }; +} + +function formatTime(iso: string | undefined): string { + if (!iso) return "–"; + const date = new Date(iso); + return Number.isNaN(date.getTime()) ? iso : date.toLocaleString(); +} + +export function ScheduledPromptsConfig({ embedded, sessionId, onClose, onReloaded }: Props) { + const { t } = useI18n(); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [cancelling, setCancelling] = useState(null); + const [reloadNeeded, setReloadNeeded] = useState(false); + const [reloading, setReloading] = useState(false); + + const refresh = useCallback(async () => { + try { + setData(await request("GET")); + setError(null); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } + }, []); + + useEffect(() => { + void refresh(); + const timer = setInterval(() => void refresh(), REFRESH_MS); + return () => clearInterval(timer); + }, [refresh]); + + const toggle = async (enabled: boolean) => { + setSaving(true); + setError(null); + try { + setData(await request("PUT", { enabled })); + setReloadNeeded(Boolean(sessionId)); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setSaving(false); + } + }; + + const cancel = async (cwd: string, jobId: string) => { + setCancelling(`${cwd}\n${jobId}`); + setError(null); + try { + setData(await request("DELETE", { cwd, jobId })); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + void refresh(); + } finally { + setCancelling(null); + } + }; + + const reloadSession = async () => { + if (!sessionId) return; + setReloading(true); + try { + await sendAgentCommand(sessionId, { type: "reload" }); + setReloadNeeded(false); + onReloaded?.(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setReloading(false); + } + }; + + const enabled = data?.enabled ?? false; + + return ( + +
+
+ {t("schedule.enableTitle")} + {t("schedule.enableDescription")} + {reloadNeeded && {t("schedule.reloadRequired")}} + {error && {error}} +
+
+ {reloadNeeded && sessionId && ( + void reloadSession()} disabled={reloading || saving}> + {reloading ? t("agents.reloading") : t("agents.reloadSession")} + + )} + void toggle(next)} + /> +
+
+
+ {t("schedule.active")} + {data === null ? ( + {t("schedule.loading")} + ) : !enabled ? ( + {t("schedule.disabledNotice")} + ) : data.jobs.length === 0 ? ( + {t("schedule.empty")} + ) : ( +
    + {data.jobs.map((job) => { + const key = `${job.cwd}\n${job.id}`; + return ( +
  • +
    +
    + {job.name} + {job.scheduleText} +
    +
    {job.prompt}
    +
    + {shortenPath(job.cwd)} + {job.boundToSession ? t("schedule.scopeSession") : t("schedule.scopeWorkdir")} + {t("schedule.nextRun")}: {formatTime(job.nextRun)} + {t("schedule.lastRun")}: {formatTime(job.lastRun)} + {t("schedule.runs")}: {job.runCount} +
    +
    + void cancel(job.cwd, job.id)} + > + {cancelling === key ? t("schedule.cancelling") : t("schedule.cancel")} + +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/components/SettingsPanel.tsx b/components/SettingsPanel.tsx index c5f467638b..8d0d7aceeb 100644 --- a/components/SettingsPanel.tsx +++ b/components/SettingsPanel.tsx @@ -30,6 +30,7 @@ import { import { ModelsConfig } from "./ModelsConfig"; import { setupPushSubscription } from "@/lib/push-client"; import { SkillsConfig } from "./SkillsConfig"; +import { ScheduledPromptsConfig } from "./ScheduledPromptsConfig"; import { AgentsConfig } from "./AgentsConfig"; import { PluginsConfig } from "./PluginsConfig"; import { McpConfig } from "./McpConfig"; @@ -70,6 +71,7 @@ export function SettingsSectionIcon({ section, size = 16, strokeWidth = 1.8 }: { if (section === "models") return ; if (section === "skills") return ; if (section === "agents") return ; + if (section === "schedule") return ; if (section === "mcp") return ; return ; } @@ -527,6 +529,7 @@ export function SettingsPanel({ { id: "agents", label: t("common.agents") }, { id: "plugins", label: t("common.plugins") }, { id: "mcp", label: t("settings.mcp") }, + { id: "schedule", label: t("settings.schedule") }, ] as const).map((item) => ({ ...item, requiresProject: settingsSectionRequiresProject(item.id) })); const sectionRequiresProject = settingsSectionRequiresProject(section); @@ -627,6 +630,7 @@ export function SettingsPanel({ {cwd && sectionHost("plugins", )} {/* No project needed: the global mcp.json is listed alone, and a project adds its group. */} {sectionHost("mcp", )} + {sectionHost("schedule", )} diff --git a/lib/api-types.ts b/lib/api-types.ts index 64073d97b6..c3e20164bd 100644 --- a/lib/api-types.ts +++ b/lib/api-types.ts @@ -11,6 +11,11 @@ export interface SubagentSettingsResponse { maxConcurrent: number; } +export interface ScheduledPromptsResponse { + enabled: boolean; + jobs: import("./scheduled-prompts").ActiveScheduledJob[]; +} + /** Code mode's one choice (ADR 0006): Automatic writes nothing, Always on adds `+codemode` to the global defaultTools. */ export type McpCodemodePreference = "automatic" | "always"; diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index cd3d53ef39..2e9f753cd7 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -76,7 +76,24 @@ export const enLocale: LocalePlugin = { "settings.pushRegisterFailed": "❌ Registration failed:", "settings.usePowerShell": "Use PowerShell instead of Bash", "settings.projectRequired": "Open a project to configure this section", + "schedule.title": "Scheduled prompts", + "schedule.enableTitle": "Enable scheduled prompts", + "schedule.enableDescription": "Lets the agent schedule prompts to itself with the schedule_prompt tool. Turning it off stops all timers; jobs stay in .pi/schedule-prompts.json.", + "schedule.reloadRequired": "Reload the current session to apply this change.", + "schedule.active": "Active scheduled prompts", + "schedule.loading": "Loading...", + "schedule.empty": "No active scheduled prompts.", + "schedule.disabledNotice": "Scheduled prompts are turned off.", + "schedule.cancel": "Cancel", + "schedule.cancelling": "Cancelling...", + "schedule.nextRun": "Next run", + "schedule.lastRun": "Last run", + "schedule.runs": "Runs", + "schedule.scopeSession": "This session only", + "schedule.scopeWorkdir": "Every session in this folder", + "schedule.cancelLabel": "Cancel scheduled prompt {name}", "settings.mcp": "MCP", + "settings.schedule": "Schedule", "auth.prompt": "Sign in to continue", "auth.password": "Password", "auth.logIn": "Log in", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index bb1c64868e..c7ebcafd6f 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -76,7 +76,24 @@ export const zhCNLocale: LocalePlugin = { "settings.pushRegisterFailed": "❌ 注册失败:", "settings.usePowerShell": "使用 PowerShell 替代 Bash", "settings.projectRequired": "打开项目后才能配置此项", + "schedule.title": "定时提示", + "schedule.enableTitle": "启用定时提示", + "schedule.enableDescription": "允许智能体通过 schedule_prompt 工具给自己安排提示。关闭后会停止所有计时器;任务仍保留在 .pi/schedule-prompts.json。", + "schedule.reloadRequired": "重新加载当前会话后生效。", + "schedule.active": "进行中的定时提示", + "schedule.loading": "正在加载...", + "schedule.empty": "没有进行中的定时提示。", + "schedule.disabledNotice": "定时提示已关闭。", + "schedule.cancel": "取消", + "schedule.cancelling": "正在取消...", + "schedule.nextRun": "下次运行", + "schedule.lastRun": "上次运行", + "schedule.runs": "运行次数", + "schedule.scopeSession": "仅此会话", + "schedule.scopeWorkdir": "此文件夹中的所有会话", + "schedule.cancelLabel": "取消定时提示 {name}", "settings.mcp": "MCP", + "settings.schedule": "定时", "auth.prompt": "登录后继续使用", "auth.password": "密码", "auth.logIn": "登录", diff --git a/lib/i18n/messages/zh-TW.ts b/lib/i18n/messages/zh-TW.ts index fe29c7f3bc..d76799a354 100644 --- a/lib/i18n/messages/zh-TW.ts +++ b/lib/i18n/messages/zh-TW.ts @@ -76,7 +76,24 @@ export const zhTWLocale: LocalePlugin = { "settings.pushRegisterFailed": "❌ 註冊失敗:", "settings.usePowerShell": "使用 PowerShell 取代 Bash", "settings.projectRequired": "開啟專案後才能設定此項", + "schedule.title": "排程提示", + "schedule.enableTitle": "啟用排程提示", + "schedule.enableDescription": "允許代理程式透過 schedule_prompt 工具為自己排程提示。關閉後會停止所有計時器;工作仍保留在 .pi/schedule-prompts.json。", + "schedule.reloadRequired": "重新載入目前工作階段後生效。", + "schedule.active": "進行中的排程提示", + "schedule.loading": "正在載入...", + "schedule.empty": "沒有進行中的排程提示。", + "schedule.disabledNotice": "排程提示已關閉。", + "schedule.cancel": "取消", + "schedule.cancelling": "正在取消...", + "schedule.nextRun": "下次執行", + "schedule.lastRun": "上次執行", + "schedule.runs": "執行次數", + "schedule.scopeSession": "僅此工作階段", + "schedule.scopeWorkdir": "此資料夾中的所有工作階段", + "schedule.cancelLabel": "取消排程提示 {name}", "settings.mcp": "MCP", + "settings.schedule": "排程", "auth.prompt": "登入後繼續使用", "auth.password": "密碼", "auth.logIn": "登入", diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index af1692de01..21b9a92aa1 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -47,6 +47,8 @@ import { } from "./subagents"; import { createSubagentController } from "./subagent-runtime"; import { isBuiltInSubagentsEnabled } from "./subagent-settings"; +import { createScheduledPromptsExtension, preferPiWebScheduledPromptsExtension } from "./scheduled-prompts"; +import { isScheduledPromptsEnabled } from "./scheduled-prompts-settings"; import { resolveShellTools } from "./powershell-settings"; import { CHAT_ONLY_RESOURCE_LOADER_OPTIONS, contextFilesSystemPrompt } from "./chat-only"; import { createExactSystemPromptExtension } from "./exact-system-prompt"; @@ -2617,8 +2619,11 @@ export async function startRpcSession( () => listSubagentProfiles(sessionCwd), isBuiltInSubagentsEnabled, ), + createScheduledPromptsExtension(isScheduledPromptsEnabled), ], - extensionsOverride: (base) => preferUserBashExtension(preferPiWebSubagentExtension(base)), + extensionsOverride: (base) => preferUserBashExtension( + preferPiWebScheduledPromptsExtension(preferPiWebSubagentExtension(base)), + ), }, ...(trustReloadOptions ? { resourceLoaderReloadOptions: trustReloadOptions } : {}), }); diff --git a/lib/scheduled-prompts-settings.ts b/lib/scheduled-prompts-settings.ts new file mode 100644 index 0000000000..549d82291b --- /dev/null +++ b/lib/scheduled-prompts-settings.ts @@ -0,0 +1,42 @@ +import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; +import { writePrivateFileAtomicSync } from "./atomic-file"; + +export function getScheduledPromptsSettingsPath(agentDir = getAgentDir()): string { + return join(agentDir, "scheduled-prompts", "settings.json"); +} + +function readStored(settingsPath: string): Record { + if (!existsSync(settingsPath)) return {}; + const parsed: unknown = JSON.parse(readFileSync(settingsPath, "utf8")); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Invalid scheduled prompts settings: expected an object"); + } + return parsed as Record; +} + +/** + * On unless switched off. Damaged settings fail closed: a file the user wrote to + * turn the feature off must not leave it running because of a typo. + */ +export function isScheduledPromptsEnabled(settingsPath = getScheduledPromptsSettingsPath()): boolean { + try { + return readStored(settingsPath).enabled !== false; + } catch { + return false; + } +} + +export function writeScheduledPromptsEnabled(enabled: boolean, settingsPath = getScheduledPromptsSettingsPath()): boolean { + const stored = (() => { + try { + return readStored(settingsPath); + } catch { + return {}; + } + })(); + mkdirSync(dirname(settingsPath), { recursive: true }); + writePrivateFileAtomicSync(settingsPath, JSON.stringify({ ...stored, version: 1, enabled }, null, 2)); + return enabled; +} diff --git a/lib/scheduled-prompts.test.mjs b/lib/scheduled-prompts.test.mjs new file mode 100644 index 0000000000..4c824cab0b --- /dev/null +++ b/lib/scheduled-prompts.test.mjs @@ -0,0 +1,234 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url); +const { + ScheduleStorage, + PromptScheduler, + validateSchedule, + parseRelativeTime, + createScheduledPromptsExtension, + listActiveScheduledJobs, + cancelScheduledJob, + stopAllScheduledPrompts, + preferPiWebScheduledPromptsExtension, +} = await jiti.import("./scheduled-prompts.ts"); +const { hasActiveSessionLivenessProvider } = await jiti.import("./session-liveness.ts"); +const { isScheduledPromptsEnabled, writeScheduledPromptsEnabled } = await jiti.import("./scheduled-prompts-settings.ts"); + +function tempDir() { + return mkdtempSync(join(tmpdir(), "pi-web-schedule-")); +} + +function fakePi() { + const handlers = new Map(); + const tools = new Map(); + const sent = []; + return { + handlers, + tools, + sent, + on(name, handler) { handlers.set(name, handler); }, + registerTool(tool) { tools.set(tool.name, tool); }, + sendMessage(message) { sent.push({ kind: "marker", message }); }, + sendUserMessage(text, options) { sent.push({ kind: "prompt", text, options }); }, + }; +} + +function context(cwd, sessionId = "s1") { + return { cwd, sessionManager: { getSessionId: () => sessionId, getEntries: () => [] } }; +} + +async function run(tool, params, ctx) { + const result = await tool.execute("call", params, undefined, undefined, ctx); + return result.content[0].text; +} + +test("validateSchedule resolves relative times and rejects bad input", () => { + const now = Date.parse("2026-01-01T00:00:00Z"); + assert.equal(parseRelativeTime("+5m", now), "2026-01-01T00:05:00.000Z"); + assert.deepEqual(validateSchedule("once", "+10s", now), { ok: true, schedule: "2026-01-01T00:00:10.000Z" }); + assert.equal(validateSchedule("once", "2025-01-01T00:00:00Z", now).ok, false); + assert.equal(validateSchedule("once", "+2s", now).ok, true); + assert.equal(validateSchedule("once", "2026-01-01T00:00:02Z", now).ok, false); + assert.deepEqual(validateSchedule("interval", "5m"), { ok: true, schedule: "5m", intervalMs: 300_000 }); + assert.equal(validateSchedule("interval", "soon").ok, false); + assert.equal(validateSchedule("cron", "* * * * *").ok, false); + assert.equal(validateSchedule("cron", "0 * * * * *").ok, true); + assert.equal(validateSchedule("cron", "99 * * * * *").ok, false); +}); + +test("an interval job sends a marker and the prompt, then records the run", async () => { + const cwd = tempDir(); + try { + const storage = new ScheduleStorage(cwd); + const pi = fakePi(); + storage.add({ id: "j1", name: "tick", schedule: "1s", prompt: "ping", enabled: true, type: "interval", intervalMs: 20, createdAt: "x", runCount: 0, session: "s1" }); + const scheduler = new PromptScheduler(cwd, "s1", storage, pi); + scheduler.start(); + await new Promise((resolve) => setTimeout(resolve, 70)); + scheduler.stop(); + const prompts = pi.sent.filter((entry) => entry.kind === "prompt"); + assert.ok(prompts.length >= 2); + assert.deepEqual(prompts[0].options, { deliverAs: "followUp" }); + assert.equal(prompts[0].text, "ping"); + assert.equal(pi.sent[0].kind, "marker"); + assert.equal(pi.sent[0].message.customType, "scheduled_prompt"); + assert.equal(storage.get("j1").runCount, prompts.length); + assert.equal(storage.get("j1").lastStatus, "success"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("a job bound to another session is not fired; an unbound one is", async () => { + const cwd = tempDir(); + try { + const storage = new ScheduleStorage(cwd); + const pi = fakePi(); + const base = { schedule: "1s", prompt: "p", enabled: true, type: "interval", intervalMs: 20, createdAt: "x", runCount: 0 }; + storage.add({ ...base, id: "other", name: "other", session: "s2" }); + storage.add({ ...base, id: "shared", name: "shared" }); + const scheduler = new PromptScheduler(cwd, "s1", storage, pi); + scheduler.start(); + assert.equal(scheduler.isScheduled("other"), false); + assert.equal(scheduler.isScheduled("shared"), true); + scheduler.stop(); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("a one-shot job fires once and disables itself", async () => { + const cwd = tempDir(); + try { + const storage = new ScheduleStorage(cwd); + const pi = fakePi(); + const scheduler = new PromptScheduler(cwd, "s1", storage, pi); + const job = { id: "o", name: "once", schedule: new Date(Date.now() + 30).toISOString(), prompt: "p", enabled: true, type: "once", createdAt: "x", runCount: 0 }; + storage.add(job); + scheduler.add(job); + await new Promise((resolve) => setTimeout(resolve, 90)); + assert.equal(pi.sent.filter((entry) => entry.kind === "prompt").length, 1); + assert.equal(storage.get("o").enabled, false); + scheduler.stop(); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("the tool adds, lists, and removes jobs; Settings can list and cancel them", async () => { + const cwd = tempDir(); + const pi = fakePi(); + try { + await createScheduledPromptsExtension(() => true).factory(pi); + const ctx = context(cwd); + pi.handlers.get("session_start")({}, ctx); + const tool = pi.tools.get("schedule_prompt"); + + assert.match(await run(tool, { action: "add", name: "daily", schedule: "0 0 9 * * *", prompt: "report" }, ctx), /Created/); + assert.match(await run(tool, { action: "add", name: "daily", schedule: "0 0 9 * * *", prompt: "again" }, ctx), /already exists/); + assert.match(await run(tool, { action: "add", schedule: "0 0 9 * * *" }, ctx), /Missing required/); + + const active = listActiveScheduledJobs().filter((job) => job.cwd === cwd); + assert.equal(active.length, 1); + assert.equal(active[0].name, "daily"); + assert.equal(active[0].boundToSession, true); + assert.ok(active[0].nextRun); + assert.equal(hasActiveSessionLivenessProvider({ sessionId: "s1" }), true); + + assert.equal(cancelScheduledJob(cwd, active[0].id), true); + assert.equal(listActiveScheduledJobs().filter((job) => job.cwd === cwd).length, 0); + assert.equal(new ScheduleStorage(cwd).all().length, 0); + assert.equal(hasActiveSessionLivenessProvider({ sessionId: "s1" }), false); + assert.match(await run(tool, { action: "list" }, ctx), /No scheduled prompts/); + + pi.handlers.get("session_shutdown")(); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("workdir scope leaves the job unbound", async () => { + const cwd = tempDir(); + const pi = fakePi(); + try { + await createScheduledPromptsExtension().factory(pi); + const ctx = context(cwd); + pi.handlers.get("session_start")({}, ctx); + await run(pi.tools.get("schedule_prompt"), { action: "add", schedule: "5m", type: "interval", prompt: "p", scope: "workdir" }, ctx); + assert.equal(new ScheduleStorage(cwd).all()[0].session, undefined); + pi.handlers.get("session_shutdown")(); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("a fired prompt cannot schedule more prompts", async () => { + const cwd = tempDir(); + const pi = fakePi(); + try { + await createScheduledPromptsExtension().factory(pi); + const ctx = context(cwd); + ctx.sessionManager.getEntries = () => [{ type: "custom_message", customType: "scheduled_prompt" }]; + pi.handlers.get("session_start")({}, ctx); + assert.match(await run(pi.tools.get("schedule_prompt"), { action: "add", schedule: "5m", type: "interval", prompt: "p" }, ctx), /infinite loops/); + pi.handlers.get("session_shutdown")(); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("a disabled feature registers no tool, and stopAll ends live timers", async () => { + const off = fakePi(); + await createScheduledPromptsExtension(() => false).factory(off); + assert.equal(off.tools.size, 0); + + const cwd = tempDir(); + const pi = fakePi(); + try { + await createScheduledPromptsExtension().factory(pi); + const ctx = context(cwd, "s9"); + pi.handlers.get("session_start")({}, ctx); + await run(pi.tools.get("schedule_prompt"), { action: "add", schedule: "5m", type: "interval", prompt: "p" }, ctx); + assert.equal(listActiveScheduledJobs().filter((job) => job.cwd === cwd).length, 1); + stopAllScheduledPrompts(); + assert.equal(listActiveScheduledJobs().filter((job) => job.cwd === cwd).length, 0); + assert.equal(JSON.parse(readFileSync(join(cwd, ".pi", "schedule-prompts.json"), "utf8")).jobs.length, 1); + pi.handlers.get("session_shutdown")(); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("the enabled setting defaults on, persists, and fails closed when damaged", () => { + const dir = tempDir(); + try { + const path = join(dir, "scheduled-prompts", "settings.json"); + assert.equal(isScheduledPromptsEnabled(path), true); + writeScheduledPromptsEnabled(false, path); + assert.equal(isScheduledPromptsEnabled(path), false); + writeScheduledPromptsEnabled(true, path); + assert.equal(isScheduledPromptsEnabled(path), true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("the legacy pi-schedule-prompt extension yields to the integrated one", () => { + const host = { path: "", tools: new Map([["schedule_prompt", {}]]) }; + const legacy = { path: "/x/node_modules/pi-schedule-prompt/dist/index.js", sourceInfo: { source: "npm:pi-schedule-prompt" }, tools: new Map([["schedule_prompt", {}]]) }; + const other = { path: "/x/other.js", tools: new Map() }; + const base = { + extensions: [host, legacy, other], + errors: [{ path: host.path, error: `Tool "schedule_prompt" conflicts with ${legacy.path}` }], + }; + const result = preferPiWebScheduledPromptsExtension(base); + assert.deepEqual(result.extensions, [host, other]); + assert.deepEqual(result.errors, []); + assert.equal(preferPiWebScheduledPromptsExtension({ extensions: [legacy], errors: [] }).extensions.length, 1); +}); diff --git a/lib/scheduled-prompts.ts b/lib/scheduled-prompts.ts new file mode 100644 index 0000000000..d59457607c --- /dev/null +++ b/lib/scheduled-prompts.ts @@ -0,0 +1,587 @@ +import { randomBytes } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { Cron } from "croner"; +import { StringEnum, Type } from "@earendil-works/pi-ai"; +import { defineTool, type ExtensionAPI, type InlineExtension } from "@earendil-works/pi-coding-agent"; +import { writePrivateFileAtomicSync } from "./atomic-file"; +import { registerSessionLivenessProvider } from "./session-liveness"; + +// Scheduled prompts: the `schedule_prompt` tool, brought in from the +// `pi-schedule-prompt` extension (https://github.com/tintinweb/pi-schedule-prompt). +// Jobs live in `/.pi/schedule-prompts.json`, the same file and shape the +// extension writes, so existing jobs carry over. Pi Web has no TUI widget or +// `/schedule-prompt` command: Settings › Scheduled prompts lists and cancels jobs. +// Jobs that name a `model` (the extension's subagent mode) are not supported. + +export const SCHEDULED_PROMPTS_EXTENSION_NAME = "pi-web-scheduled-prompts"; +export const SCHEDULE_PROMPT_TOOL_NAME = "schedule_prompt"; +export const SCHEDULED_PROMPT_MESSAGE_TYPE = "scheduled_prompt"; +const STORE_FILE = "schedule-prompts.json"; +const MIN_ONCE_DELAY_MS = 5_000; + +export type ScheduledJobType = "cron" | "once" | "interval"; +export type ScheduledJobScope = "session" | "workdir"; + +export interface ScheduledJob { + id: string; + name: string; + /** Cron expression, ISO timestamp, or interval text such as `5m`. */ + schedule: string; + prompt: string; + enabled: boolean; + type: ScheduledJobType; + intervalMs?: number; + createdAt: string; + lastRun?: string; + lastStatus?: "success" | "error" | "running"; + nextRun?: string; + runCount: number; + description?: string; + /** The session that fires the job. Absent: every session in the cwd fires it. */ + session?: string; +} + +interface ScheduleStore { + jobs: ScheduledJob[]; + version: number; +} + +/** What Settings shows for one active job. */ +export interface ActiveScheduledJob { + id: string; + name: string; + schedule: string; + scheduleText: string; + type: ScheduledJobType; + prompt: string; + description?: string; + cwd: string; + /** Sessions that currently fire the job. */ + sessionIds: string[]; + boundToSession: boolean; + runCount: number; + lastRun?: string; + lastStatus?: ScheduledJob["lastStatus"]; + nextRun?: string; +} + +// --------------------------------------------------------------------------- +// Storage +// --------------------------------------------------------------------------- + +export class ScheduleStorage { + private readonly dir: string; + private readonly path: string; + + constructor(cwd: string) { + this.dir = join(cwd, ".pi"); + this.path = join(this.dir, STORE_FILE); + } + + load(): ScheduleStore { + try { + if (existsSync(this.path)) { + const parsed: unknown = JSON.parse(readFileSync(this.path, "utf8")); + if (parsed && typeof parsed === "object" && Array.isArray((parsed as ScheduleStore).jobs)) { + return { ...(parsed as ScheduleStore), version: 1 }; + } + } + } catch (error) { + console.error("[pi-web] failed to read scheduled prompts:", error instanceof Error ? error.message : error); + } + return { jobs: [], version: 1 }; + } + + private save(store: ScheduleStore): void { + mkdirSync(this.dir, { recursive: true }); + writePrivateFileAtomicSync(this.path, JSON.stringify(store, null, 2)); + } + + all(): ScheduledJob[] { + return this.load().jobs; + } + + get(id: string): ScheduledJob | undefined { + return this.all().find((job) => job.id === id); + } + + hasName(name: string): boolean { + return this.all().some((job) => job.name === name); + } + + add(job: ScheduledJob): void { + const store = this.load(); + store.jobs.push(job); + this.save(store); + } + + remove(id: string): boolean { + const store = this.load(); + const jobs = store.jobs.filter((job) => job.id !== id); + if (jobs.length === store.jobs.length) return false; + this.save({ ...store, jobs }); + return true; + } + + update(id: string, partial: Partial): boolean { + const store = this.load(); + const job = store.jobs.find((entry) => entry.id === id); + if (!job) return false; + Object.assign(job, partial); + this.save(store); + return true; + } +} + +// --------------------------------------------------------------------------- +// Schedule parsing +// --------------------------------------------------------------------------- + +type ValidatedSchedule = + | { ok: true; schedule: string; intervalMs?: number } + | { ok: false; error: string }; + +const UNIT_MS: Record = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }; + +export function parseIntervalMs(text: string): number | null { + const match = text.trim().match(/^(\d+)\s*([smhd])$/i); + if (!match) return null; + const value = Number(match[1]) * UNIT_MS[match[2].toLowerCase()]; + return value > 0 ? value : null; +} + +/** `+10s`, `+5m`, `+1h`, `+2d` to an ISO timestamp from now. */ +export function parseRelativeTime(text: string, now = Date.now()): string | null { + const match = text.trim().match(/^\+(\d+)\s*([smhd])$/i); + if (!match) return null; + return new Date(now + Number(match[1]) * UNIT_MS[match[2].toLowerCase()]).toISOString(); +} + +export function validateSchedule(type: ScheduledJobType, schedule: string, now = Date.now()): ValidatedSchedule { + if (type === "interval") { + const intervalMs = parseIntervalMs(schedule); + if (!intervalMs) return { ok: false, error: `Invalid interval: ${schedule}. Use a form like '30s', '5m', '1h'.` }; + return { ok: true, schedule, intervalMs }; + } + if (type === "once") { + const relative = parseRelativeTime(schedule, now); + if (relative) return { ok: true, schedule: relative }; + const date = new Date(schedule); + if (Number.isNaN(date.getTime())) { + return { ok: false, error: `Invalid timestamp: ${schedule}. Use ISO format or a relative time like '+10s', '+5m'.` }; + } + const delay = date.getTime() - now; + if (delay < 0) return { ok: false, error: `Timestamp is in the past: ${date.toISOString()}.` }; + if (delay < MIN_ONCE_DELAY_MS) { + return { ok: false, error: `Timestamp is too soon (${Math.round(delay / 1000)}s). Use a relative time like '+${Math.ceil(delay / 1000)}s', or at least 5s ahead.` }; + } + return { ok: true, schedule: date.toISOString() }; + } + const fields = schedule.trim().split(/\s+/); + if (fields.length !== 6) { + return { ok: false, error: `Cron expression must have 6 fields (second minute hour day month weekday), got ${fields.length}. Example: "0 * * * * *" is every minute.` }; + } + try { + new Cron(schedule, () => {}).stop(); + return { ok: true, schedule }; + } catch (error) { + return { ok: false, error: `Invalid cron expression: ${error instanceof Error ? error.message : String(error)}` }; + } +} + +export function describeSchedule(type: ScheduledJobType, schedule: string): string { + if (type === "interval") return `every ${schedule}`; + if (type === "once") { + const date = new Date(schedule); + return Number.isNaN(date.getTime()) ? schedule : date.toLocaleString(); + } + return schedule; +} + +// --------------------------------------------------------------------------- +// Scheduler (one per session) +// --------------------------------------------------------------------------- + +export interface SchedulerHost { + sendMessage: ExtensionAPI["sendMessage"]; + sendUserMessage: ExtensionAPI["sendUserMessage"]; +} + +export function isLoadedFor(job: ScheduledJob, sessionId: string): boolean { + return !job.session || job.session === sessionId; +} + +export class PromptScheduler { + private readonly crons = new Map(); + private readonly timers = new Map | ReturnType>(); + + constructor( + readonly cwd: string, + readonly sessionId: string, + private readonly storage: ScheduleStorage, + private readonly host: SchedulerHost, + ) {} + + /** Schedule this session's enabled jobs. Clears a `running` flag a killed process left behind. */ + start(): void { + for (const job of this.storage.all()) { + if (!isLoadedFor(job, this.sessionId)) continue; + if (job.lastStatus === "running") this.storage.update(job.id, { lastStatus: undefined }); + if (job.enabled) this.schedule(job); + } + } + + stop(): void { + for (const cron of this.crons.values()) cron.stop(); + this.crons.clear(); + for (const timer of this.timers.values()) clearTimeout(timer); + this.timers.clear(); + } + + add(job: ScheduledJob): void { + if (job.enabled) this.schedule(job); + } + + unschedule(id: string): void { + this.crons.get(id)?.stop(); + this.crons.delete(id); + const timer = this.timers.get(id); + if (timer) clearTimeout(timer); + this.timers.delete(id); + } + + update(job: ScheduledJob): void { + this.unschedule(job.id); + if (job.enabled) this.schedule(job); + } + + isScheduled(id: string): boolean { + return this.crons.has(id) || this.timers.has(id); + } + + nextRun(id: string): Date | null { + return this.crons.get(id)?.nextRun() ?? null; + } + + /** Whether an enabled job of this session is waiting to fire or is firing. */ + hasActiveJobs(): boolean { + return this.storage.all().some((job) => + isLoadedFor(job, this.sessionId) && (job.lastStatus === "running" || (job.enabled && this.isScheduled(job.id)))); + } + + private schedule(job: ScheduledJob): void { + try { + if (job.type === "interval" && job.intervalMs) { + this.timers.set(job.id, setInterval(() => this.fire(job.id), job.intervalMs)); + } else if (job.type === "once") { + const delay = new Date(job.schedule).getTime() - Date.now(); + if (delay > 0) { + this.timers.set(job.id, setTimeout(() => { + this.timers.delete(job.id); + this.fire(job.id); + // A one-shot is spent once it fires. + this.storage.update(job.id, { enabled: false }); + }, delay)); + } else { + this.storage.update(job.id, { enabled: false, lastStatus: "error" }); + } + } else { + this.crons.set(job.id, new Cron(job.schedule, () => this.fire(job.id))); + } + } catch (error) { + console.error(`[pi-web] failed to schedule prompt ${job.id}:`, error instanceof Error ? error.message : error); + } + } + + private fire(id: string): void { + // Read again: the job may have been removed, disabled, or rebound since it was scheduled. + const job = this.storage.get(id); + if (!job?.enabled || !isLoadedFor(job, this.sessionId)) return; + try { + this.storage.update(id, { lastStatus: "running" }); + // A visible marker only. Its content stays out of the model context: the prompt + // reaches the model once, through sendUserMessage below. + this.host.sendMessage({ + customType: SCHEDULED_PROMPT_MESSAGE_TYPE, + content: [{ type: "text", text: `🕐 Scheduled: ${job.name}` }], + display: true, + details: { jobId: job.id, jobName: job.name, prompt: job.prompt }, + }); + this.host.sendUserMessage(job.prompt, { deliverAs: "followUp" }); + this.storage.update(id, { + lastRun: new Date().toISOString(), + lastStatus: "success", + runCount: (this.storage.get(id)?.runCount ?? job.runCount) + 1, + nextRun: this.nextRun(id)?.toISOString(), + }); + } catch (error) { + console.error(`[pi-web] scheduled prompt ${id} failed:`, error instanceof Error ? error.message : error); + this.storage.update(id, { lastRun: new Date().toISOString(), lastStatus: "error" }); + } + } +} + +// --------------------------------------------------------------------------- +// Registry of live schedulers, shared by sessions and the Settings API +// --------------------------------------------------------------------------- + +const REGISTRY_KEY = Symbol.for("@agegr/pi-web/scheduled-prompts/v1"); + +function registry(): Map { + const holder = globalThis as unknown as Record | undefined>; + return (holder[REGISTRY_KEY] ??= new Map()); +} + +/** Enabled jobs that a live session will fire, one entry per job. */ +export function listActiveScheduledJobs(): ActiveScheduledJob[] { + const byKey = new Map(); + for (const scheduler of registry().values()) { + const jobs = new ScheduleStorage(scheduler.cwd).all(); + for (const job of jobs) { + if (!job.enabled || !isLoadedFor(job, scheduler.sessionId) || !scheduler.isScheduled(job.id)) continue; + const key = `${scheduler.cwd}\n${job.id}`; + const existing = byKey.get(key); + if (existing) { + existing.sessionIds.push(scheduler.sessionId); + continue; + } + byKey.set(key, { + id: job.id, + name: job.name, + schedule: job.schedule, + scheduleText: describeSchedule(job.type, job.schedule), + type: job.type, + prompt: job.prompt, + ...(job.description ? { description: job.description } : {}), + cwd: scheduler.cwd, + sessionIds: [scheduler.sessionId], + boundToSession: Boolean(job.session), + runCount: job.runCount, + ...(job.lastRun ? { lastRun: job.lastRun } : {}), + ...(job.lastStatus ? { lastStatus: job.lastStatus } : {}), + ...((scheduler.nextRun(job.id)?.toISOString() ?? job.nextRun) ? { nextRun: scheduler.nextRun(job.id)?.toISOString() ?? job.nextRun } : {}), + }); + } + } + return [...byKey.values()].sort((a, b) => (a.nextRun ?? "￿").localeCompare(b.nextRun ?? "￿")); +} + +/** Delete a job from its project file and stop every live session that fires it. */ +export function cancelScheduledJob(cwd: string, jobId: string): boolean { + const removed = new ScheduleStorage(cwd).remove(jobId); + for (const scheduler of registry().values()) { + if (scheduler.cwd === cwd) scheduler.unschedule(jobId); + } + return removed; +} + +/** Stop all timers, as when the feature is switched off. Jobs stay in their files. */ +export function stopAllScheduledPrompts(): void { + for (const scheduler of registry().values()) scheduler.stop(); +} + +// --------------------------------------------------------------------------- +// Tool and extension +// --------------------------------------------------------------------------- + +const Params = Type.Object({ + action: StringEnum(["add", "remove", "list", "enable", "disable", "update", "cleanup"], { description: "Action to perform" }), + name: Type.Optional(Type.String({ description: "Job name, generated if omitted" })), + schedule: Type.Optional(Type.String({ description: "Required for add. 6-field cron expression, ISO timestamp, relative time (+10s, +5m), or interval (5m, 1h)." })), + prompt: Type.Optional(Type.String({ description: "Required for add. The prompt to send to this session when the job fires." })), + jobId: Type.Optional(Type.String({ description: "Job ID for remove, enable, disable, or update" })), + type: Type.Optional(StringEnum(["cron", "once", "interval"], { description: "Job type. Use 'once' for relative or ISO times. Default is cron." })), + description: Type.Optional(Type.String({ description: "Optional job description" })), + scope: Type.Optional(StringEnum(["session", "workdir"], { description: "'session' (default): only this session fires the job. 'workdir': every session in this working directory fires it." })), +}); + +const DESCRIPTION = + "Schedule prompts at times or intervals; each one is sent to this session when it fires. For action='add' you MUST give both 'schedule' and 'prompt'. " + + "Schedule formats: 6-field cron with seconds ('0 * * * * *' is every minute), ISO timestamp, relative time (+10s, +5m, +1h), or interval (5m, 1h). " + + "Type defaults to 'cron'; use 'once' for relative or ISO times. Actions: add (schedule+prompt), list, remove/enable/disable/update (jobId), cleanup (remove disabled jobs)."; + +function text(value: string, isError = false) { + return { content: [{ type: "text" as const, text: value }], details: undefined, ...(isError ? { isError: true } : {}) }; +} + +function formatJob(job: ScheduledJob, next: Date | null): string { + return [ + `${job.enabled ? "✓" : "✗"} ${job.name} (${job.id})`, + ` Type: ${job.type} | Schedule: ${job.schedule} | Scope: ${job.session ? "session" : "workdir"}`, + ` Prompt: ${job.prompt}`, + ` ${job.lastRun ? `Last: ${job.lastRun}` : "Never run"}${next ? ` | Next: ${next.toISOString()}` : ""}`, + ` Runs: ${job.runCount} | Status: ${job.lastStatus ?? "pending"}`, + ...(job.description ? [` Description: ${job.description}`] : []), + ].join("\n"); +} + +export type ScheduledPromptsEnabledProvider = () => boolean; + +export function createScheduledPromptsExtension(isEnabled: ScheduledPromptsEnabledProvider = () => true): InlineExtension { + return { + name: SCHEDULED_PROMPTS_EXTENSION_NAME, + factory: async (pi) => { + if (!isEnabled()) return; + let scheduler: PromptScheduler | undefined; + let storage: ScheduleStorage | undefined; + let releaseLiveness: (() => void) | undefined; + + const teardown = () => { + releaseLiveness?.(); + releaseLiveness = undefined; + if (scheduler) { + scheduler.stop(); + if (registry().get(scheduler.sessionId) === scheduler) registry().delete(scheduler.sessionId); + } + scheduler = undefined; + storage = undefined; + }; + + // Idempotent: session_start also fires on reload, resume, and fork. + const initialize = (ctx: { cwd: string; sessionManager: { getSessionId(): string } }) => { + teardown(); + const sessionId = ctx.sessionManager.getSessionId(); + storage = new ScheduleStorage(ctx.cwd); + const created = new PromptScheduler(ctx.cwd, sessionId, storage, pi); + scheduler = created; + created.start(); + registry().set(sessionId, created); + // A session with live schedules must not be evicted as idle. + releaseLiveness = registerSessionLivenessProvider({ + name: SCHEDULED_PROMPTS_EXTENSION_NAME, + sessionId, + isActive: () => created.hasActiveJobs(), + }); + }; + + pi.on("session_start", (_event, ctx) => initialize(ctx)); + pi.on("session_shutdown", () => teardown()); + + pi.registerTool(defineTool({ + name: SCHEDULE_PROMPT_TOOL_NAME, + label: "Schedule Prompt", + description: DESCRIPTION, + promptSnippet: "Schedule a prompt to be sent to this session later or on a recurring schedule", + parameters: Params, + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + if (!scheduler || !storage) initialize(ctx); + const active = scheduler!; + const store = storage!; + const sessionId = ctx.sessionManager.getSessionId(); + try { + switch (params.action) { + case "add": { + // A fired prompt must not schedule more prompts, or one mistake loops forever. + const recent = ctx.sessionManager.getEntries().slice(-10); + if (recent.some((entry) => entry.type === "custom_message" && entry.customType === SCHEDULED_PROMPT_MESSAGE_TYPE)) { + throw new Error("Cannot create scheduled prompts from within a scheduled prompt execution. This prevents infinite loops."); + } + if (!params.schedule || !params.prompt) { + throw new Error("Missing required parameters for add: provide both 'schedule' (for example '+10m' or '0 0 9 * * *') and 'prompt'."); + } + const name = params.name || `job-${randomBytes(3).toString("hex")}`; + if (store.hasName(name)) throw new Error(`A job named "${name}" already exists. Use another name or remove it first.`); + const type = (params.type ?? "cron") as ScheduledJobType; + const validated = validateSchedule(type, params.schedule); + if (!validated.ok) throw new Error(validated.error); + const job: ScheduledJob = { + id: randomBytes(5).toString("hex"), + name, + schedule: validated.schedule, + prompt: params.prompt, + enabled: true, + type, + ...(validated.intervalMs ? { intervalMs: validated.intervalMs } : {}), + createdAt: new Date().toISOString(), + runCount: 0, + ...(params.description ? { description: params.description } : {}), + ...(params.scope === "workdir" ? {} : { session: sessionId }), + }; + store.add(job); + active.add(job); + return text(`✓ Created scheduled prompt "${job.name}" (${job.id})\nType: ${job.type}\nSchedule: ${job.schedule}\nPrompt: ${job.prompt}`); + } + case "remove": { + if (!params.jobId) throw new Error("jobId is required for remove"); + const job = store.get(params.jobId); + if (!job) throw new Error(`Job not found: ${params.jobId}`); + cancelScheduledJob(ctx.cwd, job.id); + return text(`✓ Removed scheduled prompt "${job.name}" (${job.id})`); + } + case "enable": + case "disable": { + if (!params.jobId) throw new Error(`jobId is required for ${params.action}`); + const job = store.get(params.jobId); + if (!job) throw new Error(`Job not found: ${params.jobId}`); + const enabled = params.action === "enable"; + store.update(job.id, { enabled }); + active.update({ ...job, enabled }); + return text(`✓ ${enabled ? "Enabled" : "Disabled"} scheduled prompt "${job.name}" (${job.id})`); + } + case "update": { + if (!params.jobId) throw new Error("jobId is required for update"); + const job = store.get(params.jobId); + if (!job) throw new Error(`Job not found: ${params.jobId}`); + const updates: Partial = {}; + if (params.name) updates.name = params.name; + if (params.prompt) updates.prompt = params.prompt; + if (params.description !== undefined) updates.description = params.description; + if (params.schedule) { + const validated = validateSchedule(job.type, params.schedule); + if (!validated.ok) throw new Error(validated.error); + updates.schedule = validated.schedule; + if (validated.intervalMs !== undefined) updates.intervalMs = validated.intervalMs; + } + store.update(job.id, updates); + active.update({ ...job, ...updates }); + return text(`✓ Updated scheduled prompt "${updates.name ?? job.name}" (${job.id})`); + } + case "cleanup": { + const disabled = store.all().filter((job) => !job.enabled && isLoadedFor(job, sessionId)); + for (const job of disabled) cancelScheduledJob(ctx.cwd, job.id); + return text(disabled.length === 0 + ? "No disabled jobs to clean up" + : `✓ Removed ${disabled.length} disabled job(s):\n${disabled.map((job) => ` - ${job.name} (${job.id})`).join("\n")}`); + } + case "list": { + const jobs = store.all().filter((job) => isLoadedFor(job, sessionId)); + if (jobs.length === 0) return text("No scheduled prompts."); + return text(["Scheduled prompts:", "", ...jobs.map((job) => formatJob(job, active.nextRun(job.id)))].join("\n\n")); + } + default: + throw new Error(`Unknown action: ${String(params.action)}`); + } + } catch (error) { + return text(`✗ Error: ${error instanceof Error ? error.message : String(error)}`, true); + } + }, + })); + }, + }; +} + +/** Keep Pi Web's integrated implementation when the `pi-schedule-prompt` package is also loaded. */ +export function preferPiWebScheduledPromptsExtension }[]; errors: { path: string; error: string }[] }>(base: T): T { + const hostPath = ``; + const host = base.extensions.find((extension) => extension.path === hostPath); + if (!host?.tools.has(SCHEDULE_PROMPT_TOOL_NAME)) return base; + const legacyPaths = new Set(base.extensions + .filter((extension) => extension.path !== hostPath && extension.tools.has(SCHEDULE_PROMPT_TOOL_NAME)) + .filter((extension) => { + const source = (extension.sourceInfo?.source ?? "").replace(/^npm:/, "").split("@")[0]; + return source === "pi-schedule-prompt" + || extension.path.replaceAll("\\", "/").split("/").includes("pi-schedule-prompt"); + }) + .map((extension) => extension.path)); + if (legacyPaths.size === 0) return base; + return { + ...base, + extensions: base.extensions.filter((extension) => !legacyPaths.has(extension.path)), + errors: base.errors.filter((error) => { + if (legacyPaths.has(error.path)) return false; + if (error.path !== hostPath) return true; + return ![...legacyPaths].some((legacyPath) => error.error === `Tool "${SCHEDULE_PROMPT_TOOL_NAME}" conflicts with ${legacyPath}`); + }), + }; +} diff --git a/lib/settings-navigation.ts b/lib/settings-navigation.ts index 5367458194..ec019513ad 100644 --- a/lib/settings-navigation.ts +++ b/lib/settings-navigation.ts @@ -5,6 +5,7 @@ export const SETTINGS_SECTION_VALUES = [ "agents", "plugins", "mcp", + "schedule", ] as const; export type SettingsSection = (typeof SETTINGS_SECTION_VALUES)[number]; diff --git a/package-lock.json b/package-lock.json index c0324b64e8..01d67ef8d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@earendil-works/pi-tui": "1.2.0", "@xterm/addon-fit": "0.11.0", "@xterm/xterm": "6.0.0", + "croner": "10.0.1", "js-yaml": "^5.2.3", "next": "16.3.8", "node-pty": "1.2.0-beta.15", @@ -4420,6 +4421,25 @@ "layout-base": "^1.0.0" } }, + "node_modules/croner": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz", + "integrity": "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==", + "funding": [ + { + "type": "other", + "url": "https://paypal.me/hexagonpp" + }, + { + "type": "github", + "url": "https://github.com/sponsors/hexagon" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/package.json b/package.json index 23f89eee6c..46cbec376a 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@earendil-works/pi-tui": "1.2.0", "@xterm/addon-fit": "0.11.0", "@xterm/xterm": "6.0.0", + "croner": "10.0.1", "js-yaml": "^5.2.3", "next": "16.3.8", "node-pty": "1.2.0-beta.15",