feat: add persistent cron-based recurring reminders
Replace in-memory-only setTimeout scheduling with a DB-backed system that survives broker restarts. Adds: - `scheduled_message` table in mesh schema (Drizzle + raw CREATE TABLE for zero-downtime deploys) - Minimal 5-field cron parser (no dependencies) with next-fire-time calculation for recurring entries - On broker boot, all non-cancelled entries are loaded from PostgreSQL and timers re-armed automatically - CLI `schedule_reminder` MCP tool accepts optional `cron` expression - CLI `remind` command accepts `--cron` flag - One-shot reminders remain backward compatible — no cron field = same behavior as before Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ export interface RemindFlags {
|
||||
mesh?: string;
|
||||
in?: string; // e.g. "2h", "30m", "90s"
|
||||
at?: string; // ISO or HH:MM
|
||||
cron?: string; // 5-field cron expression for recurring
|
||||
to?: string; // default: self
|
||||
json?: boolean;
|
||||
}
|
||||
@@ -88,19 +89,21 @@ export async function runRemind(
|
||||
return;
|
||||
}
|
||||
|
||||
// claudemesh remind <message> --in <duration> | --at <time>
|
||||
// claudemesh remind <message> --in <duration> | --at <time> | --cron <expr>
|
||||
const message = action ?? positional.join(" ");
|
||||
if (!message) {
|
||||
console.error("Usage: claudemesh remind <message> --in <duration>");
|
||||
console.error(" claudemesh remind <message> --at <time>");
|
||||
console.error(' claudemesh remind <message> --cron "0 */2 * * *"');
|
||||
console.error(" claudemesh remind list");
|
||||
console.error(" claudemesh remind cancel <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const deliverAt = parseDeliverAt(flags);
|
||||
if (deliverAt === null) {
|
||||
console.error('Specify when: --in <duration> (e.g. "2h", "30m") or --at <time> (e.g. "15:00")');
|
||||
const isCron = !!flags.cron;
|
||||
const deliverAt = isCron ? 0 : parseDeliverAt(flags);
|
||||
if (!isCron && deliverAt === null) {
|
||||
console.error('Specify when: --in <duration> (e.g. "2h", "30m"), --at <time> (e.g. "15:00"), or --cron <expression>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -123,12 +126,17 @@ export async function runRemind(
|
||||
targetSpec = client.getSessionPubkey() ?? "*";
|
||||
}
|
||||
|
||||
const result = await client.scheduleMessage(targetSpec, message, deliverAt);
|
||||
const result = await client.scheduleMessage(targetSpec, message, deliverAt ?? 0, false, flags.cron);
|
||||
if (!result) { console.error("✗ Broker did not acknowledge — check connection"); process.exit(1); }
|
||||
|
||||
if (flags.json) { console.log(JSON.stringify(result)); return; }
|
||||
const when = new Date(result.deliverAt).toLocaleString();
|
||||
const toLabel = !flags.to || flags.to === "self" ? "yourself" : flags.to;
|
||||
console.log(`✓ Reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} at ${when}`);
|
||||
if (isCron) {
|
||||
const nextFire = new Date(result.deliverAt).toLocaleString();
|
||||
console.log(`✓ Recurring reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} — cron: ${flags.cron}, next fire: ${nextFire}`);
|
||||
} else {
|
||||
const when = new Date(result.deliverAt).toLocaleString();
|
||||
console.log(`✓ Reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} at ${when}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -463,16 +463,22 @@ Your message mode is "${messageMode}".
|
||||
to?: string;
|
||||
deliver_at?: number;
|
||||
in_seconds?: number;
|
||||
cron?: string;
|
||||
};
|
||||
if (!sArgs.message) return text("schedule_reminder: `message` required", true);
|
||||
|
||||
const isCron = !!sArgs.cron;
|
||||
|
||||
let deliverAt: number;
|
||||
if (sArgs.deliver_at) {
|
||||
if (isCron) {
|
||||
// For cron, deliverAt is ignored by the broker — set to 0
|
||||
deliverAt = 0;
|
||||
} else if (sArgs.deliver_at) {
|
||||
deliverAt = Number(sArgs.deliver_at);
|
||||
} else if (sArgs.in_seconds) {
|
||||
deliverAt = Date.now() + Number(sArgs.in_seconds) * 1_000;
|
||||
} else {
|
||||
return text("schedule_reminder: provide `deliver_at` (ms timestamp) or `in_seconds`", true);
|
||||
return text("schedule_reminder: provide `deliver_at` (ms timestamp), `in_seconds`, or `cron` expression", true);
|
||||
}
|
||||
|
||||
const isSelf = !sArgs.to;
|
||||
@@ -496,8 +502,18 @@ Your message mode is "${messageMode}".
|
||||
}
|
||||
}
|
||||
|
||||
const result = await client.scheduleMessage(targetSpec, sArgs.message, deliverAt, true);
|
||||
const result = await client.scheduleMessage(targetSpec, sArgs.message, deliverAt, true, sArgs.cron);
|
||||
if (!result) return text("schedule_reminder: broker did not acknowledge — check connection", true);
|
||||
|
||||
if (isCron) {
|
||||
const nextFire = new Date(result.deliverAt).toISOString();
|
||||
return text(
|
||||
isSelf
|
||||
? `Recurring self-reminder scheduled (${result.scheduledId.slice(0, 8)}): "${sArgs.message.slice(0, 60)}" — cron: ${sArgs.cron}, next fire: ${nextFire}`
|
||||
: `Recurring reminder to "${sArgs.to}" scheduled (${result.scheduledId.slice(0, 8)}) — cron: ${sArgs.cron}, next fire: ${nextFire}`,
|
||||
);
|
||||
}
|
||||
|
||||
const when = new Date(result.deliverAt).toISOString();
|
||||
return text(
|
||||
isSelf
|
||||
|
||||
@@ -568,13 +568,14 @@ export const TOOLS: Tool[] = [
|
||||
{
|
||||
name: "schedule_reminder",
|
||||
description:
|
||||
"Schedule a message for future delivery. Without `to`, it fires back to yourself (a self-reminder). With `to`, it delivers to a peer, @group, or * broadcast. The broker holds it and delivers when the time arrives. Receivers see `subtype: reminder` in the push envelope.",
|
||||
"Schedule a one-shot or recurring message. Without `to`, it fires back to yourself (a self-reminder). With `to`, it delivers to a peer, @group, or * broadcast. For one-shot, provide `deliver_at` or `in_seconds`. For recurring, provide `cron` (standard 5-field expression). The broker persists schedules to the database — they survive restarts. Receivers see `subtype: reminder` in the push envelope.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: { type: "string", description: "Message or reminder text" },
|
||||
deliver_at: { type: "number", description: "Unix timestamp (ms) when to deliver" },
|
||||
in_seconds: { type: "number", description: "Alternative to deliver_at: fire after N seconds" },
|
||||
deliver_at: { type: "number", description: "Unix timestamp (ms) when to deliver (one-shot)" },
|
||||
in_seconds: { type: "number", description: "Alternative to deliver_at: fire after N seconds (one-shot)" },
|
||||
cron: { type: "string", description: "Cron expression for recurring reminders (e.g. '0 */2 * * *' for every 2 hours, '30 9 * * 1-5' for 9:30 weekdays)" },
|
||||
to: {
|
||||
type: "string",
|
||||
description: "Recipient: display name, pubkey hex, @group, or * (omit for self-reminder)",
|
||||
|
||||
@@ -415,13 +415,14 @@ export class BrokerClient {
|
||||
|
||||
// --- Scheduled messages ---
|
||||
|
||||
/** Schedule a message for future delivery. Returns { scheduledId, deliverAt } or null on timeout. */
|
||||
/** Schedule a message for future delivery. Returns { scheduledId, deliverAt, cron? } or null on timeout. */
|
||||
async scheduleMessage(
|
||||
to: string,
|
||||
message: string,
|
||||
deliverAt: number,
|
||||
isReminder = false,
|
||||
): Promise<{ scheduledId: string; deliverAt: number } | null> {
|
||||
cron?: string,
|
||||
): Promise<{ scheduledId: string; deliverAt: number; cron?: string } | null> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
|
||||
return new Promise((resolve) => {
|
||||
const reqId = this.makeReqId();
|
||||
@@ -434,6 +435,7 @@ export class BrokerClient {
|
||||
message,
|
||||
deliverAt,
|
||||
...(isReminder ? { subtype: "reminder" } : {}),
|
||||
...(cron ? { cron, recurring: true } : {}),
|
||||
_reqId: reqId,
|
||||
}));
|
||||
});
|
||||
@@ -1123,6 +1125,7 @@ export class BrokerClient {
|
||||
this.resolveFromMap(this.scheduledAckResolvers, msgReqId, {
|
||||
scheduledId: String(msg.scheduledId ?? ""),
|
||||
deliverAt: Number(msg.deliverAt ?? 0),
|
||||
...(msg.cron ? { cron: String(msg.cron) } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user