feat: merge schedule_reminder + send_later, add subtype reminder
Some checks failed
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled

- Merge send_later into schedule_reminder (optional `to` param — omit for self-reminder)
- Add subtype?: "reminder" to WSPushMessage, WSScheduleMessage, ScheduledEntry, InboundPush
- Broker handleSend now accepts optional subtype and injects into push envelope
- deliver closure passes sm.subtype so reminders surface correctly
- MCP channel meta includes subtype field; formatPush tags [REMINDER] in check_messages
- MCP server instructions document subtype and schedule_reminder/list_scheduled/cancel_scheduled
- client.scheduleMessage accepts isReminder flag, sends subtype: "reminder" on wire

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-07 22:38:41 +01:00
parent 3ff7a61e3f
commit 0bb9d71a26
5 changed files with 62 additions and 45 deletions

View File

@@ -103,7 +103,7 @@ const connectionsPerMesh = new Map<string, number>();
// Stream subscriptions: "meshId:streamName" → Set of presenceIds
const streamSubscriptions = new Map<string, Set<string>>();
// Scheduled messages: meshId → Map<scheduledId, entry>
/// Scheduled messages: meshId → Map<scheduledId, entry>
interface ScheduledEntry {
id: string;
meshId: string;
@@ -112,6 +112,7 @@ interface ScheduledEntry {
message: string;
deliverAt: number;
createdAt: number;
subtype?: "reminder";
timer: ReturnType<typeof setTimeout>;
}
const scheduledMessages = new Map<string, ScheduledEntry>(); // keyed by scheduledId
@@ -652,6 +653,7 @@ async function handleHello(
async function handleSend(
conn: PeerConn,
msg: Extract<WSClientMessage, { type: "send" }>,
subtype?: "reminder",
): Promise<void> {
const messageId = await queueMessage({
meshId: conn.meshId,
@@ -696,6 +698,7 @@ async function handleSend(
nonce: msg.nonce,
ciphertext: msg.ciphertext,
createdAt: new Date().toISOString(),
...(subtype ? { subtype } : {}),
};
for (const [pid, peer] of connections) {
@@ -1824,7 +1827,7 @@ function handleConnection(ws: WebSocket): void {
nonce: "",
ciphertext: Buffer.from(sm.message, "utf-8").toString("base64"),
};
handleSend(conn2, presenceId, fakeMsg).catch((e) =>
handleSend(conn2, fakeMsg, sm.subtype).catch((e) =>
log.warn("scheduled delivery error", { scheduled_id: scheduledId, error: String(e) }),
);
log.info("ws schedule deliver", { scheduled_id: scheduledId, to: sm.to });
@@ -1838,6 +1841,7 @@ function handleConnection(ws: WebSocket): void {
message: sm.message,
deliverAt: sm.deliverAt,
createdAt: now,
...(sm.subtype ? { subtype: sm.subtype } : {}),
timer: setTimeout(deliver, delay),
};
scheduledMessages.set(scheduledId, entry);

View File

@@ -86,6 +86,8 @@ export interface WSPushMessage {
nonce: string;
ciphertext: string;
createdAt: string;
/** Optional semantic tag — "reminder" when delivered by the scheduler. */
subtype?: "reminder";
}
/** Client → broker: manual status override (dnd, forced idle). */
@@ -673,6 +675,8 @@ export interface WSScheduleMessage {
message: string;
/** Unix timestamp (ms) when to deliver. */
deliverAt: number;
/** Optional semantic tag — "reminder" surfaces differently to the receiver. */
subtype?: "reminder";
_reqId?: string;
}

View File

@@ -123,7 +123,8 @@ function decryptFailedWarning(senderPubkey: string): string {
function formatPush(p: InboundPush, meshSlug: string): string {
const body = p.plaintext ?? decryptFailedWarning(p.senderPubkey);
return `[${meshSlug}] from ${p.senderPubkey.slice(0, 12)}… (${p.priority}, ${p.createdAt}):\n${body}`;
const tag = p.subtype === "reminder" ? " [REMINDER]" : "";
return `[${meshSlug}]${tag} from ${p.senderPubkey.slice(0, 12)}… (${p.priority}, ${p.createdAt}):\n${body}`;
}
export async function startMcpServer(): Promise<void> {
@@ -147,6 +148,8 @@ You are "${myName}"${myRole ? ` (${myRole})` : ""} — a peer in the claudemesh
## Responding to messages
When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATELY. Pause your current task, reply via send_message, then resume. Read from_name, mesh_slug, and priority from the channel attributes. Reply by setting \`to\` to the sender's from_name (display name). Stay in character per your system prompt. Do not ignore low-priority messages — acknowledge them briefly even if you defer action.
If the channel meta contains \`subtype: reminder\`, this is a scheduled reminder you set for yourself — act on it immediately (no reply needed).
## Tools
| Tool | Description |
|------|-------------|
@@ -188,6 +191,9 @@ When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATEL
| claim_task(id) | Claim an unclaimed task. |
| complete_task(id, result?) | Mark task done with optional result. |
| list_tasks(status?, assignee?) | List tasks filtered by status/assignee. |
| schedule_reminder(message, in_seconds?, deliver_at?, to?) | Schedule a reminder to yourself (no \`to\`) or a delayed message to a peer/group. Delivered as a push with \`subtype: reminder\` in the channel meta. |
| list_scheduled() | List pending scheduled reminders and messages. |
| cancel_scheduled(id) | Cancel a pending scheduled item. |
If multiple meshes are joined, prefix \`to\` with \`<mesh-slug>:\` to disambiguate (e.g. \`dev-team:Alice\`).
@@ -445,17 +451,14 @@ Your message mode is "${messageMode}".
}
// --- Scheduled messages ---
case "schedule_reminder":
case "send_later": {
case "schedule_reminder": {
const sArgs = (args ?? {}) as {
message?: string;
to?: string;
deliver_at?: number;
in_seconds?: number;
};
if (!sArgs.message) return text(`${name}: \`message\` required`, true);
const to = name === "schedule_reminder" ? "self" : (sArgs.to ?? "");
if (name === "send_later" && !to) return text("send_later: `to` required", true);
if (!sArgs.message) return text("schedule_reminder: `message` required", true);
let deliverAt: number;
if (sArgs.deliver_at) {
@@ -463,32 +466,37 @@ Your message mode is "${messageMode}".
} else if (sArgs.in_seconds) {
deliverAt = Date.now() + Number(sArgs.in_seconds) * 1_000;
} else {
return text(`${name}: provide \`deliver_at\` (ms timestamp) or \`in_seconds\``, true);
return text("schedule_reminder: provide `deliver_at` (ms timestamp) or `in_seconds`", true);
}
// For send_later, resolve display name → pubkey if needed
let targetSpec = to;
if (name === "send_later" && !to.startsWith("@") && to !== "*" && !/^[0-9a-f]{64}$/i.test(to) && to !== "self") {
const peers = await client.listPeers();
const match = peers.find((p) => p.displayName.toLowerCase() === to.toLowerCase());
if (!match) {
const names = peers.map((p) => p.displayName).join(", ");
return text(`send_later: peer "${to}" not found. Online: ${names || "(none)"}`, true);
}
targetSpec = match.pubkey;
}
if (name === "schedule_reminder") {
// Self-reminder: use own session pubkey
const isSelf = !sArgs.to;
let targetSpec: string;
if (isSelf) {
// Self-reminder: target own session pubkey
targetSpec = client.getSessionPubkey() ?? "*";
} else {
const to = sArgs.to!;
// Resolve display name → pubkey if not a raw spec
if (!to.startsWith("@") && to !== "*" && !/^[0-9a-f]{64}$/i.test(to)) {
const peers = await client.listPeers();
const match = peers.find((p) => p.displayName.toLowerCase() === to.toLowerCase());
if (!match) {
const names = peers.map((p) => p.displayName).join(", ");
return text(`schedule_reminder: peer "${to}" not found. Online: ${names || "(none)"}`, true);
}
targetSpec = match.pubkey;
} else {
targetSpec = to;
}
}
const result = await client.scheduleMessage(targetSpec, sArgs.message, deliverAt);
if (!result) return text(`${name}: broker did not acknowledge — check connection`, true);
const result = await client.scheduleMessage(targetSpec, sArgs.message, deliverAt, true);
if (!result) return text("schedule_reminder: broker did not acknowledge — check connection", true);
const when = new Date(result.deliverAt).toISOString();
return text(
name === "schedule_reminder"
? `Reminder scheduled (${result.scheduledId.slice(0, 8)}): "${sArgs.message.slice(0, 60)}" at ${when}`
: `Message to "${to}" scheduled (${result.scheduledId.slice(0, 8)}) for ${when}`,
isSelf
? `Self-reminder scheduled (${result.scheduledId.slice(0, 8)}): "${sArgs.message.slice(0, 60)}" at ${when}`
: `Reminder to "${sArgs.to}" scheduled (${result.scheduledId.slice(0, 8)}) for ${when}`,
);
}
case "list_scheduled": {
@@ -1016,6 +1024,7 @@ Your message mode is "${messageMode}".
sent_at: msg.createdAt,
delivered_at: msg.receivedAt,
kind: msg.kind,
...(msg.subtype ? { subtype: msg.subtype } : {}),
},
},
});

View File

@@ -568,32 +568,21 @@ export const TOOLS: Tool[] = [
{
name: "schedule_reminder",
description:
"Schedule a reminder message delivered back to yourself at a future time. The broker fires it as a push when the time arrives. Use to prompt yourself to check on something later.",
"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.",
inputSchema: {
type: "object",
properties: {
message: { type: "string", description: "Reminder text" },
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" },
to: {
type: "string",
description: "Recipient: display name, pubkey hex, @group, or * (omit for self-reminder)",
},
},
required: ["message"],
},
},
{
name: "send_later",
description:
"Send a message to a peer, @group, or broadcast (*) at a future time. The broker holds it and delivers when the time arrives.",
inputSchema: {
type: "object",
properties: {
to: { type: "string", description: "Recipient: display name, pubkey hex, @group, or *" },
message: { type: "string", description: "Message 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" },
},
required: ["to", "message"],
},
},
{
name: "list_scheduled",
description: "List all your pending scheduled messages: id, recipient, preview, and delivery time.",

View File

@@ -51,6 +51,8 @@ export interface InboundPush {
/** Hint for UI: "direct" (crypto_box), "channel"/"broadcast"
* (plaintext for now). */
kind: "direct" | "broadcast" | "channel" | "unknown";
/** Optional semantic tag — "reminder" when fired by the scheduler. */
subtype?: "reminder";
}
type PushHandler = (msg: InboundPush) => void;
@@ -406,6 +408,7 @@ export class BrokerClient {
to: string,
message: string,
deliverAt: number,
isReminder = false,
): Promise<{ scheduledId: string; deliverAt: number } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
@@ -413,7 +416,14 @@ export class BrokerClient {
this.scheduledAckResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.scheduledAckResolvers.delete(reqId)) resolve(null);
}, 8_000) });
this.ws!.send(JSON.stringify({ type: "schedule", to, message, deliverAt, _reqId: reqId }));
this.ws!.send(JSON.stringify({
type: "schedule",
to,
message,
deliverAt,
...(isReminder ? { subtype: "reminder" } : {}),
_reqId: reqId,
}));
});
}
@@ -927,6 +937,7 @@ export class BrokerClient {
receivedAt: new Date().toISOString(),
plaintext,
kind,
...(msg.subtype ? { subtype: msg.subtype as "reminder" } : {}),
};
this.pushBuffer.push(push);
if (this.pushBuffer.length > 500) this.pushBuffer.shift();