/** * Per-channel scheduled prompts ("crons") for monitoring/reporting. The server * fires them on a timer — no session needs to stay alive between runs, so there * is no duration cap (unlike an agent scheduling itself). Persisted per launch * directory at ~/.shadok-ai/crons/.json, so they survive restarts. */ export type CronSchedule = { kind: "interval"; everyMin: number; } | { kind: "daily"; hour: number; minute: number; } /** * One-shot: fires once at an ABSOLUTE instant, then the cron is spent. * * Stored as an epoch and not as a wall clock + tz on purpose. A `daily` is a * RULE, re-read every day, so changing the default timezone must move it * (that is what `primeCrons` does). A one-shot is an instant already * arbitrated at creation — moving it under the user because a global setting * changed would be the surprise, not the service. */ | { kind: "once"; at: number; }; export interface Cron { id: string; /** The channel this cron drives (its session is resumed and prompted). */ sessionId: string; prompt: string; schedule: CronSchedule; enabled: boolean; /** * Optional deterministic guard command, run server-side WITHOUT the LLM * before each fire (in the channel's cwd, with the profile's secrets). The * convention: print nothing = nothing to report → the agent is NOT woken (no * tokens); print something = news → the agent runs, with the output prepended * to the prompt. Lets routine monitoring cost zero tokens on quiet runs. */ check?: string; /** * IANA time zone ("Europe/Paris") a `daily` schedule is read in. Without it * the hour follows the MACHINE's zone: the same `daily:09:00` fires at 09:00 * in Paris and at 09:00 UTC on a server running UTC, i.e. 11:00 as lived. * Resolved by `cronTimeZone`: this field, else the global default (config * `timezone`), else the system zone — so nothing moves until it is set. */ tz?: string; /** ms epoch of the last fire, and the next scheduled fire. */ lastRun?: number; nextRun?: number; /** * Consecutive delivery failures being retried (see `nextRunAfterFailure`), * reset to 0 as soon as a run lands or we give up until the next slot. */ retries?: number; /** Why the last fire ended: "ok" | "quiet" | "check-failed" | a DriveReason. * Persisted so the JSON store alone tells you what happened, without logs. */ lastOutcome?: string; } /** * Mark carried by the TEXT of a prompt sent by a cron. * * Why in the content and not only in the protocol (`origin: "cron"`): the * prompt goes through the TUI, so Claude Code writes it into the transcript * like any other user message. Hiding only the direct echo left the wall of * text — the prompt PLUS the guard's output, kilobytes of it — coming back on * a page reload and in a Telegram topic's backfill, both of which re-read * `loadHistory`. * * Same choice as the `NOTHING TO SHOW` sentinel: a mark in the content, * filtered wherever we render. It has a useful side effect — the agent learns * this turn comes from a schedule, which nothing else told it. */ export declare const CRON_PROMPT_MARK = "\u23F0 [cron]"; /** Prefix a cron's prompt. Idempotent: re-marking does not double the mark. */ export declare function markCronPrompt(text: string): string; /** * Is this text a cron prompt? Deliberately STRICT — the mark must OPEN the * message. An agent (or a human) quoting "⏰ [cron]" mid-sentence must not see * its message vanish: that is the cost of an over-broad heuristic, the one * invariant 2 is about. */ export declare function isCronPrompt(text: string): boolean; /** Why a cron's delivery to its channel failed. Lives here (not in server.ts) * so `nextRunAfterFailure` can be typed and tested without the server. */ export type DriveReason = "pace-blocked" | "busy" | "error" | "gone" | "exited" | "ws-error" | "timeout"; export declare function isTransient(reason: DriveReason): boolean; /** How long to wait before replaying a lost run, and how many times. */ export declare const CRON_RETRY_DELAY_MS: number; export declare const CRON_MAX_RETRIES = 3; /** * Where to reschedule a cron whose delivery just failed transiently. * * `attempts` is how many retries already happened for this run. The result's * `nextRun` is ALWAYS in the future, which is what keeps the "advance nextRun * before firing" anti-double-fire invariant intact. */ export declare function nextRunAfterFailure(nowMs: number, scheduledNextMs: number | null, attempts: number): { nextRun: number | null; retrying: boolean; attempts: number; }; /** * Next fire time (ms epoch) for a schedule, strictly after `fromMs`. * `tz` (IANA) only applies to `daily` — an interval is a duration, it has no * time zone. Absent → the machine's local time (historical behaviour). */ export declare function nextRunFor(s: CronSchedule, fromMs: number, tz?: string | null): number; /** * What firing does to a cron's OWN schedule state, applied the instant it is * fired (before the delivery is even attempted). * * A recurring cron advances to its next slot — that is what stops a long run * from double-firing. A one-shot cannot: advancing lands on the same fixed * instant, so it would fire forever. It is DISABLED instead, which is a * stronger guarantee and reuses a state `cronTick`, `primeCrons` and * `GET /channels` already skip. A transient delivery failure re-arms it — see * `nextRunAfterFailure` with a null slot. */ export declare function stateAfterFire(s: CronSchedule, nowMs: number, tz?: string | null): { enabled: boolean; nextRun?: number; }; /** Is this an IANA zone identifier the bundled ICU knows? */ export declare function isValidTimeZone(tz: string): boolean; /** * Read a human date into the absolute instant a one-shot fires at. * * Two shapes, on purpose. A bare "2026-08-25T08:42" is a WALL CLOCK: it means * 08:42 where the cron lives, so it is resolved through `tz` — that is what the * web form, the CLI and Telegram all produce. A string carrying an explicit * offset (or Z) is already absolute and `tz` must NOT shift it; parsing that * one as a wall clock would move a correct instant by the offset. * * Returns null on anything it cannot read — never a guessed date. A reminder * silently scheduled for the wrong day is worse than a refused one. */ export declare function onceAt(tz: string, text: string): number | null; /** Validate + normalize a raw schedule object; null if invalid. */ export declare function normalizeSchedule(raw: any): CronSchedule | null; /** The machine's zone — the fallback when nothing is configured. */ export declare function systemTimeZone(): string; /** Global default (config `timezone`), ignored unless it is a valid zone. */ export declare function defaultTimeZone(): string | undefined; /** * A cron's effective zone: its own, else the global default, else the * machine's. Setting `tz` on the config therefore fixes every existing cron at * once — exactly what you want on a server running UTC. */ export declare function cronTimeZone(c: Pick): string; /** * Human label for a schedule (UI/Telegram). The zone is ALWAYS shown for a * `daily`: it is the only way to spot a server that is not in the zone you * assume (a bare "daily at 09:00" does not say 09:00 where). */ export declare function scheduleLabel(s: CronSchedule, tz?: string): string; export declare function loadCrons(): Cron[]; export declare function saveCrons(list: Cron[]): void; /** Insert or replace a cron by id. */ export declare function upsertCron(cron: Cron): void; export declare function removeCron(id: string): void; export type CronLookup = { ok: true; id: string; } | { ok: false; error: "empty" | "not-found" | "ambiguous"; matches: number; }; /** * Resolve what the user typed into a full cron id. * * Every surface shows ONLY the first 8 characters of the id (the web, skill * and Telegram `list`), so accepting a prefix is not a convenience: it is the * only way to name a cron with what is on screen. And an empty prefix must * never "match the first one" — a bare `/cron del` would delete a cron at * random. */ export declare function resolveCronId(list: Cron[], needle: string): CronLookup;