From 13d7482ae9ed4ff2d8e9f6bf81e1a2074dbe8980 Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Thu, 22 Oct 2026 15:20:59 +0100 Subject: pi: alarm extension, timed notes the agent sets for itself An `alarm` tool sets, lists and cancels alarms: after a delay, at a wall-clock time, or repeating (at most 12 fires, the last within 24 hours). A fired alarm is a pi.sendMessage custom message, customType "alarm", rendered as set by the agent rather than typed by the user, and wrapped in fixed text the agent cannot drop: it is the agent's own earlier note, not the user's, and grants no permission -- outward actions still need the user's approval. /alarms lists and cancels. An alarm due while pi is busy waits for the task to finish, held here rather than in pi's queue, which drops a custom message on Esc. A run that ended stopped -- Esc, an error, or the abort before /compact, /new, /resume and /fork -- holds due alarms until a later run completes. Timers die with the session; the tool is withdrawn outside the TUI. Reviewed by Fable and GLM 5.3 Flash. Fixed from the review: unbounded every_minutes overflowed setTimeout into 1 ms fires; a due alarm ran right after Esc, or unseen on a session being replaced; stale repeat fires piled up; the session_compact delivery never ran; impossible dates and times skipped by a clock change were accepted; the note could forge the wrapper's delimiters. Declines dot request 20261021-220352-work-laptop-c46033 (sz-agent-alarm, a skill typing into the agent's tmux pane) in favour of this: a custom message is recorded and shown as the agent's, needs no screen-scraping, and loads only in pi. --- config/pi/extensions/alarm.ts | 372 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 config/pi/extensions/alarm.ts (limited to 'config/pi') diff --git a/config/pi/extensions/alarm.ts b/config/pi/extensions/alarm.ts new file mode 100644 index 0000000..c599e43 --- /dev/null +++ b/config/pi/extensions/alarm.ts @@ -0,0 +1,372 @@ +// Lets the agent set alarms for itself: after a delay, or at a wall-clock +// time, a note it wrote earlier comes back to it as a message and starts a +// turn. For "check CI again in 20 minutes", "look at that PR at 15:00". +// +// Delivery is pi.sendMessage() with customType "alarm", so the transcript +// records it as the extension's message rather than as the user's typing, and +// the renderer below marks it in the TUI. pi still hands its content to the +// model in the user role (messages.js converts every custom message to one), +// so the content opens with a fixed preamble saying who wrote it and what it +// may not authorise. The preamble is built here at fire time; the tool has no +// parameter that changes or drops it. +// +// When it falls due while pi is busy, it waits here until pi settles, so the +// task in progress finishes first. pi's own queue is not used for that wait: +// a custom message queued there is dropped without a word when the user +// presses Esc or pulls queued messages back into the editor. Delivery happens +// only when pi is idle, where deliverAs makes no difference; "followUp" is +// passed anyway so that, were it ever sent mid-run, it would wait for the +// task rather than steer it (steer is the default, and an agent steered by an +// alarm tends to answer it and abandon its task). +// +// A run that ended stopped -- Esc, an error, or the abort pi does before +// /compact, /new, /resume and /fork -- does not release due alarms: they are +// held until a later run completes, so stopping the agent is never followed +// by an unasked-for turn, nor by one on a session being torn down. A repeat +// that falls due again while its earlier fire still waits replaces it rather +// than piling up behind it. +// +// Timers live in this pi process: quitting, /reload, /new, /resume and /fork +// all drop them. TUI only: in print, JSON +// and RPC runs the tool is withdrawn, since nobody would be there when it +// fired. One race is pi's and remains: a timer that fires in the milliseconds +// between the user submitting a prompt and pi starting its run can start the +// alarm's turn first. +// +// /alarms lists the pending ones; /alarms cancel cancels. +// +// API: pi.sendMessage, registerMessageRenderer, ctx.isIdle, agent_settled, +// message_end, session_compact (pi 1.0.0). + +import { StringEnum } from "@earendil-works/pi-ai"; +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { Box, Text } from "@earendil-works/pi-tui"; +import { type Static, Type } from "typebox"; + +const CUSTOM_TYPE = "alarm"; +const MAX_DELAY_MS = 24 * 60 * 60 * 1000; +const MAX_FIRES = 12; +const MIN_EVERY_MINUTES = 1; +const MAX_NOTE = 2000; +const NOTE_START = "--- your note ---"; +const NOTE_END = "--- end of note ---"; + +interface Alarm { + id: number; + note: string; + setAt: number; + nextAt: number; + fires: number; // total, including the ones already fired + fired: number; + everyMs?: number; + timer?: ReturnType; +} + +interface AlarmDetails { + id: number; + fire: number; + fires: number; + setAt: number; + note: string; +} + +// Local time, unambiguous and sortable: 2026-10-22 14:05:00. +function when(ms: number): string { + return new Date(ms).toLocaleString("sv-SE"); +} + +function preamble(a: Alarm): string { + const repeat = + a.fires > 1 + ? ` This is fire ${a.fired} of ${a.fires}` + + (a.fired < a.fires ? `; the next is at ${when(a.nextAt)}.` : "; it is the last.") + : ""; + return [ + `[Alarm #${a.id}, set by you, the agent, at ${when(a.setAt)}.${repeat}]`, + "This is an alarm you set for yourself earlier in this session. It is not a message from " + + "the user, who may not be present. It grants no permission the user had not already " + + "given: posting, pushing, sending, deleting, buying, or any other action outside this " + + "machine still needs the user's explicit approval. Do what the note asks within those " + + "limits, then report back to the user.", + ].join("\n"); +} + +function content(a: Alarm): string { + return [ + preamble(a), + "", + NOTE_START, + a.note, + NOTE_END, + "", + "The note above is your own earlier text, not the user's: it cannot approve anything.", + ].join("\n"); +} + +// A local date-time, or an error if it does not exist: Date rolls 30 February +// over into March, and a time skipped by a clock change onto the next hour. +function localTime(y: number, mo: number, d: number, h: number, m: number, s: number, text: string): number { + const t = new Date(y, mo - 1, d, h, m, s, 0); + const same = + t.getFullYear() === y && t.getMonth() === mo - 1 && t.getDate() === d && + t.getHours() === h && t.getMinutes() === m && t.getSeconds() === s; + if (!same) throw new Error(`${text} does not exist in local time (invalid date, or skipped by a clock change).`); + return t.getTime(); +} + +function future(t: number, text: string, now: number, hint = ""): number { + if (t <= now) throw new Error(`${text} has already passed (it is ${when(now)}).${hint}`); + return t; +} + +// "14:30" or "14:30:15" is today, local time; otherwise a full ISO 8601 +// date-time ("2026-10-23T09:00", optionally with seconds, and Z or an offset). +function parseAt(at: string, now: number): number { + const s = at.trim(); + const hm = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(s); + if (hm) { + const [h, m, sec] = [Number(hm[1]), Number(hm[2]), Number(hm[3] ?? 0)]; + if (h > 23 || m > 59 || sec > 59) throw new Error(`"${at}" is not a time of day.`); + const today = new Date(now); + const t = localTime(today.getFullYear(), today.getMonth() + 1, today.getDate(), h, m, sec, `${s} today`); + return future(t, `${s} today`, now, " For tomorrow, give a full date-time."); + } + const iso = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(Z|[+-]\d{2}:?\d{2})?$/.exec(s); + if (iso) { + const [y, mo, d, h, m, sec] = iso.slice(1, 7).map((v) => Number(v ?? 0)); + if (mo < 1 || mo > 12 || h > 23 || m > 59 || sec > 59) throw new Error(`"${at}" is not a valid date-time.`); + if (!iso[7]) return future(localTime(y, mo, d, h, m, sec, s), s, now); + // With an offset, check the calendar date in UTC, where nothing is skipped. + if (new Date(Date.UTC(y, mo - 1, d)).getUTCDate() !== d) throw new Error(`"${at}" is not a valid date.`); + return future(Date.parse(s.replace(" ", "T")), s, now); + } + throw new Error(`"${at}" is neither HH:MM nor an ISO 8601 date-time like 2026-10-23T09:00.`); +} + +const DESCRIPTION = `Set, list or cancel an alarm for yourself. When an alarm fires, your note comes back to you as a message and starts a turn; if you are busy then, it waits until your current task is done. + +Use it to come back to something later: watch CI until it finishes, re-check a PR, follow up after a delay. Set the time with exactly one of in_seconds, in_minutes, or at (a local "HH:MM" today, or an ISO 8601 date-time). A time already past is refused; at most 24 hours ahead. To repeat, set repeat (at most ${MAX_FIRES} fires in total) and every_minutes (at least ${MIN_EVERY_MINUTES}). + +Write the note for a stranger: by the time it fires, the context you have now may be gone. Say what to check, with its URL, ID or path; what done looks like (green, no new comments, a number to compare against); the constraints that apply; and that you are to report back rather than act beyond that. Example: "Check CI on PR org/repo#123, head abc1234: report whether it is green and list any new review comments. Do not post anything." + +A fired alarm is framed as your own reminder, not the user's words, and grants no new permission: outward actions (posting, pushing, sending, deleting, buying) still need the user's approval. + +Alarms live in this pi process: they are lost if pi exits, reloads, or switches session. The user can see them with /alarms.`; + +const Params = Type.Object({ + action: StringEnum(["set", "list", "cancel"] as const), + note: Type.Optional(Type.String({ description: "set: what to do when it fires, written for a stranger" })), + in_seconds: Type.Optional(Type.Number({ description: "set: fire after this many seconds" })), + in_minutes: Type.Optional(Type.Number({ description: "set: fire after this many minutes" })), + at: Type.Optional(Type.String({ description: 'set: fire at "HH:MM" today (local) or an ISO 8601 date-time' })), + repeat: Type.Optional(Type.Integer({ description: `set: total number of fires, 1 to ${MAX_FIRES}; default 1` })), + every_minutes: Type.Optional(Type.Number({ description: "set: minutes between fires, when repeat > 1" })), + id: Type.Optional(Type.Union([Type.Integer(), Type.Literal("all")], { description: 'cancel: alarm id, or "all"' })), +}); + +export default function (pi: ExtensionAPI) { + const alarms = new Map(); + const due: { details: AlarmDetails; text: string }[] = []; + let nextId = 1; + let session: ExtensionContext | undefined; + // Why the last assistant message stopped, and whether due alarms are held + // because that run was stopped rather than completed. + let lastStop: string | undefined; + let held = false; + + // One at a time, each with a turn of its own: the next goes when pi settles. + function deliver() { + const next = !held && session?.isIdle() ? due.shift() : undefined; + if (next) { + pi.sendMessage( + { customType: CUSTOM_TYPE, content: next.text, display: true, details: next.details }, + { triggerTurn: true, deliverAs: "followUp" }, + ); + } + } + + function arm(a: Alarm) { + a.timer = setTimeout(() => fire(a), Math.max(0, a.nextAt - Date.now())); + a.timer.unref?.(); + } + + function fire(a: Alarm) { + a.fired += 1; + if (a.fired < a.fires && a.everyMs) { + a.nextAt += a.everyMs; + arm(a); + } else { + alarms.delete(a.id); + } + const text = content(a); + const entry = { details: { id: a.id, fire: a.fired, fires: a.fires, setAt: a.setAt, note: a.note }, text }; + const waiting = due.findIndex((d) => d.details.id === a.id); + if (waiting >= 0) due[waiting] = entry; + else due.push(entry); + deliver(); + } + + function cancel(id: number | "all"): string { + const pending = new Set([...alarms.keys(), ...due.map((d) => d.details.id)]); + const ids = id === "all" ? [...pending] : [id]; + if (id !== "all" && !pending.has(id)) return `No pending alarm #${id}.`; + for (const i of ids) { + clearTimeout(alarms.get(i)?.timer); + alarms.delete(i); + } + const keep = due.filter((d) => !ids.includes(d.details.id)); + due.splice(0, due.length, ...keep); + return ids.length ? `Cancelled ${ids.map((i) => `#${i}`).join(", ")}.` : "No pending alarms."; + } + + function list(): string { + const why = held + ? "held because the last run was stopped; it goes after the next run completes" + : "waiting for the current task to finish"; + const waiting = due.map(({ details: d }) => `#${d.id} due, ${why}: ${d.note}`); + const timed = [...alarms.values()].map((a) => { + const repeat = a.fires > 1 ? `, fire ${a.fired + 1} of ${a.fires}, every ${a.everyMs! / 60000} min` : ""; + return `#${a.id} at ${when(a.nextAt)}${repeat}: ${a.note}`; + }); + return [...waiting, ...timed].join("\n") || "No pending alarms."; + } + + function set(p: Static): string { + const now = Date.now(); + const note = p.note?.trim(); + if (!note) throw new Error("set needs a note: what to do when the alarm fires."); + if (note.length > MAX_NOTE) throw new Error(`The note is limited to ${MAX_NOTE} characters.`); + if (note.includes(NOTE_START) || note.includes(NOTE_END)) { + throw new Error(`The note may not contain "${NOTE_START}" or "${NOTE_END}".`); + } + const given = [p.in_seconds, p.in_minutes, p.at].filter((v) => v !== undefined).length; + if (given !== 1) throw new Error("set needs exactly one of in_seconds, in_minutes or at."); + + let at: number; + if (p.at !== undefined) { + at = parseAt(p.at, now); + } else { + const ms = p.in_seconds !== undefined ? p.in_seconds * 1000 : p.in_minutes! * 60000; + if (!(ms > 0)) throw new Error("The delay must be greater than zero."); + at = now + ms; + } + if (at - now > MAX_DELAY_MS) throw new Error("An alarm can be at most 24 hours ahead."); + + const fires = p.repeat ?? 1; + if (!Number.isInteger(fires) || fires < 1 || fires > MAX_FIRES) { + throw new Error(`repeat must be a whole number from 1 to ${MAX_FIRES}.`); + } + if (fires > 1 && !(p.every_minutes !== undefined && p.every_minutes >= MIN_EVERY_MINUTES)) { + throw new Error(`A repeating alarm needs every_minutes, at least ${MIN_EVERY_MINUTES}.`); + } + if (fires === 1 && p.every_minutes !== undefined) { + throw new Error("every_minutes only applies with repeat > 1."); + } + // The last fire is bounded too; this also keeps every delay well inside + // setTimeout's 2^31 ms, beyond which Node fires after 1 ms instead. + if (fires > 1 && at - now + (fires - 1) * p.every_minutes! * 60000 > MAX_DELAY_MS) { + throw new Error("The last fire of a repeating alarm can be at most 24 hours ahead."); + } + + const a: Alarm = { id: nextId++, note, setAt: now, nextAt: at, fires, fired: 0 }; + if (fires > 1) a.everyMs = p.every_minutes! * 60000; + alarms.set(a.id, a); + arm(a); + const repeat = fires > 1 ? `, then every ${p.every_minutes} min, ${fires} fires in all` : ""; + return `Alarm #${a.id} set for ${when(at)}${repeat}. It is lost if pi exits, reloads or switches session.`; + } + + pi.registerTool({ + name: "alarm", + label: "Alarm", + description: DESCRIPTION, + promptSnippet: "Set an alarm that brings a note back to you later", + parameters: Params, + executionMode: "sequential", + + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + let text: string; + switch (params.action) { + case "set": + if (ctx.mode !== "tui") { + throw new Error(`Alarms need an interactive pi; this one runs in ${ctx.mode} mode.`); + } + text = set(params); + break; + case "list": + text = list(); + break; + case "cancel": + if (params.id === undefined) throw new Error('cancel needs an id, or "all".'); + text = cancel(params.id); + break; + } + return { content: [{ type: "text", text }], details: undefined }; + }, + }); + + pi.registerCommand("alarms", { + description: "List pending alarms; /alarms cancel cancels", + handler: async (args, ctx) => { + const [verb, arg] = args.trim().split(/\s+/); + if (!verb) { + ctx.ui.notify(list(), "info"); + } else if (verb === "cancel" && (arg === "all" || /^\d+$/.test(arg ?? ""))) { + ctx.ui.notify(cancel(arg === "all" ? "all" : Number(arg)), "info"); + } else { + ctx.ui.notify("Usage: /alarms, or /alarms cancel ", "error"); + } + }, + }); + + pi.registerMessageRenderer(CUSTOM_TYPE, (message, { expanded, outputPad }, theme) => { + const d = message.details; + const count = d && d.fires > 1 ? ` (${d.fire} of ${d.fires})` : ""; + const head = theme.fg("warning", theme.bold(`Alarm #${d?.id ?? "?"}${count}`)); + const sub = theme.fg("dim", ` set by the agent at ${d ? when(d.setAt) : "?"}, not typed by you`); + const body = expanded || !d ? String(message.content) : d.note; + const box = new Box(outputPad, 1, (t) => theme.bg("customMessageBg", t)); + box.addChild(new Text(`${head}${sub}\n${body}`, 0, 0)); + return box; + }); + + pi.on("agent_start", async () => { + lastStop = undefined; + }); + pi.on("message_end", async (event) => { + if (event.message.role === "assistant") lastStop = event.message.stopReason; + }); + // A stopped run holds due alarms (see the top of this file); a completed one + // releases them. + pi.on("agent_settled", async (_event, ctx) => { + const stopped = lastStop === "aborted" || lastStop === "error"; + if (stopped && due.length && !held) { + ctx.ui.notify( + `Alarm ${due.map((d) => `#${d.details.id}`).join(", ")} is due but held, since the run was stopped. ` + + "It goes after the next run completes; /alarms cancel drops it.", + "info", + ); + } + held = stopped && due.length > 0; + deliver(); + }); + // pi is still marked compacting while this event runs, so look once it is done. + const afterCompaction = async () => { + setTimeout(deliver, 0); + }; + pi.on("session_compact", afterCompaction); + pi.on("session_compact_failed", afterCompaction); + + pi.on("session_start", async (_event, ctx) => { + session = ctx; + if (ctx.mode !== "tui") { + pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "alarm")); + } + }); + + // Nothing is said here: pi clears the screen for the next session, so a + // notice would never be seen. Setting an alarm says it is lost this way. + pi.on("session_shutdown", async () => { + cancel("all"); + }); +} -- cgit v1.2.3