summaryrefslogtreecommitdiff
path: root/config/pi
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-22 15:20:59 +0100
committerIgor Soarez <igor@soarez.org>2026-10-22 15:20:59 +0100
commit13d7482ae9ed4ff2d8e9f6bf81e1a2074dbe8980 (patch)
treed781eb286b09304864a5591e89a64135183fb638 /config/pi
parent599b0bf8bd460fe061a053f1725c87b83773ab4c (diff)
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.
Diffstat (limited to 'config/pi')
-rw-r--r--config/pi/extensions/alarm.ts372
1 files changed, 372 insertions, 0 deletions
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 <id|all> 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<typeof setTimeout>;
+}
+
+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<number, Alarm>();
+ 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<AlarmDetails>(
+ { 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<typeof Params>): 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 <id|all> 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 <id|all>", "error");
+ }
+ },
+ });
+
+ pi.registerMessageRenderer<AlarmDetails>(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 <id> 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");
+ });
+}