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:
Alejandro Gutiérrez
2026-04-07 23:33:47 +01:00
parent 58ba01f20f
commit e87380775f
6 changed files with 332 additions and 39 deletions

View File

@@ -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}`);
}
});
}

View File

@@ -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

View File

@@ -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)",

View File

@@ -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;
}