/** * pi-web-scheduler companion extension. * * - Tools for the agent to create/list/manage scheduled jobs (writes the shared store). * - `/jobs` command. * - Executes hosted agent steps requested by the PI WEB server plugin and enforces the * per-job tool allowlist (also for headless `pi -p` runs via PI_SCHEDULER_ALLOWED_TOOLS). */ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { describeSchedule } from "./core/schedule.js"; import { SchedulerService } from "./core/service.js"; import { defaultStoreRoot, JobStore } from "./core/store.js"; import type { JobInput } from "./core/validate.js"; import { AGENT_REPLY, AGENT_REQUEST, toolAllowed, type AgentReplyMessage } from "./browser/protocol.js"; const ALLOWED_TOOLS_ENV = "PI_SCHEDULER_ALLOWED_TOOLS"; const MAX_TEXT = 60_000; /** Some harnesses pass object/array tool arguments as JSON strings. */ function parseMaybeJson(value: unknown): unknown { if (typeof value !== "string") return value; const t = value.trim(); if (!(t.startsWith("{") || t.startsWith("["))) return value; try { return JSON.parse(t); } catch { return value; } } function text(value: string, details?: unknown) { return { content: [{ type: "text" as const, text: value }], details }; } export default function companion(pi: ExtensionAPI): void { const store = new JobStore(defaultStoreRoot()); const service = new SchedulerService(store); let context: ExtensionContext | undefined; // ---- tool allowlist guard ------------------------------------------------- const envAllow = (process.env[ALLOWED_TOOLS_ENV] ?? "").split(",").map((s) => s.trim()).filter(Boolean); let active: { requestId: string; prompt: string; tools: string[]; started: boolean; text: string; stopReason: string; error?: string } | undefined; pi.on("tool_call", (event) => { const allow = active?.started ? active.tools : envAllow; if (allow.length > 0 && !toolAllowed(event.toolName, allow)) { return { block: true, reason: `Tool '${event.toolName}' non consentito per questo job schedulato (allowlist: ${allow.join(", ")})` }; } return undefined; }); // ---- hosted agent runs (requested by the server plugin) ------------------- const reply = (msg: AgentReplyMessage) => { pi.events.emit(AGENT_REPLY, msg); }; pi.on("session_start", (_event, ctx) => { context = ctx; }); pi.on("session_shutdown", () => { if (active) reply({ requestId: active.requestId, status: "failed", error: "Session shutdown or reload during scheduled run" }); active = undefined; context = undefined; }); pi.on("before_agent_start", (event) => { if (!active) return; if (!active.started && event.prompt === active.prompt) active.started = true; else if (active.started) active.error = "Another prompt interfered with the scheduled run"; }); pi.on("message_end", (event) => { if (active?.started !== true || event.message.role !== "assistant") return; active.text = event.message.content.filter((p) => p.type === "text").map((p) => p.text).join("\n"); active.stopReason = event.message.stopReason; if (event.message.stopReason === "error" || event.message.stopReason === "aborted") { active.error = event.message.errorMessage ?? `Run ${event.message.stopReason}`; } }); pi.on("agent_settled", () => { if (active?.started !== true) return; const result = active; active = undefined; const error = result.error ?? (!result.text.trim() ? "Run ended without a final text response" : undefined); if (error) reply({ requestId: result.requestId, status: "failed", error }); else reply({ requestId: result.requestId, status: "completed", text: result.text.slice(0, MAX_TEXT) }); }); pi.events.on(AGENT_REQUEST, (data) => { const d = data as { requestId?: unknown; prompt?: unknown; tools?: unknown }; if (typeof d !== "object" || d === null || typeof d.requestId !== "string" || typeof d.prompt !== "string") return; const requestId = d.requestId; const tools = Array.isArray(d.tools) ? d.tools.filter((t): t is string => typeof t === "string") : []; if (!context || active || !context.isIdle() || context.hasPendingMessages()) { reply({ requestId, status: "failed", error: "Companion not ready or session busy" }); return; } active = { requestId, prompt: d.prompt, tools, started: false, text: "", stopReason: "" }; try { pi.sendUserMessage(d.prompt); reply({ requestId, status: "accepted" }); } catch (e) { active = undefined; reply({ requestId, status: "failed", error: e instanceof Error ? e.message : String(e) }); } }); // ---- tools for the agent --------------------------------------------------- pi.registerTool({ name: "scheduler_create_job", label: "Scheduler: crea job", description: [ "Crea (o sostituisce, se 'id' esiste e replace=true) un job pianificato eseguito dallo scheduler PI WEB anche quando questa sessione è chiusa.", "schedule: {at:'2026-10-08T09:00:00+02:00'} | {cron:'30 23 * * *', tz:'Europe/Rome'} | {every:'2h', anchor?:ISO}.", "steps (pipeline, 1..20), ognuno con id univoco e type:", "- command: {argv:['az','vm','list','-o','json'], parseJson?:true, okExitCodes?:[0], cwd?, env?} — niente shell", "- http: {url, method?, headers?, body?, parseJson?, okStatus?}", "- condition: {all|any:[{path:'steps.m.json.value[*].avg', aggregate?:'max|min|avg|sum|count|first|last', op:'>|>=|<|<=|==|!=|contains|exists', value}], onFalse?:'continue|stop|retry'}", "- agent: {prompt, tools?:['read','bash','mcp__*'], mode?:'auto|hosted|headless', model?}", "- notify: {message, severity?:'info|warning|error'}", "Ogni step può avere when:'' e continueOnError. Template: {{steps..stdout}}, {{steps..json.x}}, {{steps..text}}, {{job.name}}, {{run.scheduledFor}}.", "misfire: skip|run-once|ask (default skip). retry: {maxAttempts, backoff:'30m', factor?, until?:ISO} — si applica a run falliti o con condition onFalse=retry.", "Preferisci step deterministici (command/http/condition) e usa agent solo quando serve ragionamento.", ].join("\n"), parameters: Type.Object({ name: Type.String({ description: "Nome leggibile del job" }), id: Type.Optional(Type.String({ description: "ID slug opzionale [a-z0-9-]" })), description: Type.Optional(Type.String()), cwd: Type.Optional(Type.String({ description: "Directory di lavoro assoluta (default: cwd della sessione)" })), schedule: Type.Any({ description: "{at} | {cron, tz?} | {every, anchor?}" }), steps: Type.Array(Type.Any(), { description: "Pipeline di step" }), misfire: Type.Optional(Type.String({ description: "skip | run-once | ask" })), misfireGrace: Type.Optional(Type.String({ description: "es. '5m'" })), timeout: Type.Optional(Type.String({ description: "Timeout complessivo, es. '30m'" })), retry: Type.Optional(Type.Any({ description: "{maxAttempts, backoff, factor?, until?}" })), tags: Type.Optional(Type.Array(Type.String())), enabled: Type.Optional(Type.Boolean()), replace: Type.Optional(Type.Boolean({ description: "Sostituisci il job con lo stesso id" })), }), async execute(_id, params, _signal, _onUpdate, ctx) { const { replace, ...raw } = params as Record & { replace?: boolean }; const rest: Record = { ...raw }; for (const k of ["schedule", "steps", "retry", "tags"]) if (k in rest) rest[k] = parseMaybeJson(rest[k]); const input = { ...rest, cwd: (rest.cwd as string | undefined) ?? ctx.cwd, createdBy: `pi-session:${ctx.sessionManager.getSessionId?.() ?? "unknown"}` } as JobInput; const job = await service.upsert(input, replace ? {} : { mustNotExist: true }); return text( `Job '${job.id}' salvato: ${describeSchedule(job.schedule)}; prossima esecuzione ${job.state.nextRunAt ?? "nessuna"}.` + `\nVerrà eseguito dallo scheduler PI WEB (o dalla CLI 'pi-scheduler tick').`, { job }, ); }, }); pi.registerTool({ name: "scheduler_list_jobs", label: "Scheduler: elenca job", description: "Elenca i job pianificati (del cwd corrente o tutti) con stato, prossima esecuzione e ultimo esito.", parameters: Type.Object({ all: Type.Optional(Type.Boolean({ description: "true = tutti i job, non solo quelli del cwd" })) }), async execute(_id, params, _signal, _onUpdate, ctx) { const views = await service.listJobs(params.all ? undefined : { cwd: ctx.cwd }); if (views.length === 0) return text("Nessun job pianificato.", { jobs: [] }); const lines = views.map(({ job }) => `- ${job.id} [${job.enabled ? "attivo" : "in pausa"}] ${job.name} — ${describeSchedule(job.schedule)} — next: ${job.state.nextRunAt ?? "-"} — ultimo: ${job.state.lastStatus ?? "-"}${job.state.pendingMisfire ? " — MISFIRE in attesa di decisione" : ""}`); return text(lines.join("\n"), { jobs: views.map((v) => v.job) }); }, }); pi.registerTool({ name: "scheduler_manage_job", label: "Scheduler: gestisci job", description: "Azioni su un job: enable, disable, delete, run_now, reschedule (richiede schedule), misfire_run, misfire_skip.", parameters: Type.Object({ id: Type.String(), action: Type.String({ description: "enable | disable | delete | run_now | reschedule | misfire_run | misfire_skip" }), schedule: Type.Optional(Type.Any({ description: "Nuovo schedule per reschedule" })), }), async execute(_id, params) { const { id, action } = params; switch (action) { case "enable": case "disable": { const job = await service.setEnabled(id, action === "enable"); return text(`Job '${id}' ${job.enabled ? "attivato" : "messo in pausa"}. Next: ${job.state.nextRunAt ?? "-"}`); } case "delete": return text((await service.remove(id)) ? `Job '${id}' eliminato (storico run conservato).` : `Job '${id}' non trovato.`); case "run_now": { await service.runNow(id); return text(`Esecuzione di '${id}' richiesta: partirà al prossimo tick dello scheduler.`); } case "reschedule": { if (!params.schedule) throw new Error("schedule richiesto"); const job = await service.reschedule(id, parseMaybeJson(params.schedule) as JobInput["schedule"]); return text(`Job '${id}' riprogrammato: ${describeSchedule(job.schedule)}; next ${job.state.nextRunAt ?? "-"}`); } case "misfire_run": case "misfire_skip": await service.resolveMisfire(id, action === "misfire_run" ? "run" : "skip"); return text(`Misfire di '${id}' risolto (${action}).`); default: throw new Error(`azione non valida: ${action}`); } }, }); pi.registerTool({ name: "scheduler_job_runs", label: "Scheduler: storico run", description: "Storico esecuzioni di un job, o dettaglio completo di un run (runId) con output degli step.", parameters: Type.Object({ id: Type.String(), runId: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), }), async execute(_id, params) { if (params.runId) { const run = await service.getRun(params.id, params.runId); return text(JSON.stringify(run, null, 2).slice(0, MAX_TEXT), { run }); } const detail = await service.detail(params.id, params.limit ?? 20); const lines = detail.runs.map((r) => `- ${r.id} ${r.status} (${r.trigger}, tentativo ${r.attempt}) ${r.startedAt}${r.error ? ` — ${r.error}` : ""}${r.sessionIds.length ? ` — sessione ${r.sessionIds.join(", ")}` : ""}`); return text(`Prossime esecuzioni: ${detail.preview.join(", ") || "-"}\n${lines.join("\n") || "Nessun run."}`, detail); }, }); pi.registerCommand("jobs", { description: "Mostra i job pianificati (pi-web-scheduler)", handler: async (_args, ctx) => { const views = await service.listJobs(); const pending = views.filter((v) => v.job.state.pendingMisfire || v.job.state.lastStatus === "failed"); ctx.ui.notify( views.length === 0 ? "Nessun job pianificato." : `${views.length} job, ${views.filter((v) => v.job.enabled).length} attivi${pending.length ? `, ${pending.length} da verificare (${pending.map((v) => v.job.id).join(", ")})` : ""}. Dettagli: pannello Scheduler in PI WEB o tool scheduler_list_jobs.`, pending.length ? "warning" : "info", ); }, }); }