diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-23 10:34:07 +0100 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-23 10:34:07 +0100 |
| commit | 3a519e6282408c19ed2aad17f7ff54d8a87e26f4 (patch) | |
| tree | 307e87ce1af5e7a41b4029cac46b883430e18652 | |
| parent | 13d7482ae9ed4ff2d8e9f6bf81e1a2074dbe8980 (diff) | |
pi alarm: steer a due alarm into a running task
An alarm that fell due mid-task used to wait in the extension until the
run settled. Now it is steered in: pi adds it after the tool calls in
progress finish, before the next model call, and the model weighs it
against the task. The alarm text tells it to say which it chose.
pi's Esc empties its queue and drops queued extension messages without
a word, so an alarm stays listed until message_end shows it in the
conversation. One not seen by the end of a stopped run is held and sent
again after the next run completes; resending into that run could
deliver it twice, since pi takes one queued steer per turn and other
aborts leave the original queued.
| -rw-r--r-- | config/pi/extensions/alarm.ts | 118 |
1 files changed, 80 insertions, 38 deletions
diff --git a/config/pi/extensions/alarm.ts b/config/pi/extensions/alarm.ts index c599e43..182abe0 100644 --- a/config/pi/extensions/alarm.ts +++ b/config/pi/extensions/alarm.ts @@ -1,6 +1,6 @@ // 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". +// time, a note it wrote earlier comes back to it as a message. 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 @@ -10,21 +10,28 @@ // 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). +// When pi is idle, an alarm that falls due starts a run. When a run is going, +// it is steered into it: pi adds it to the conversation once the tool calls +// in progress have finished, before the next model call, which weighs it +// against the task at hand. It does not cancel a tool call that is running. +// +// A steered alarm sits in pi's queue until then, and pi empties that queue +// without a word when the user presses Esc (or pulls queued messages back +// into the editor): only what the user typed comes back. So an alarm stays +// listed here until message_end shows it in the conversation. One sent but +// not seen by the time the run settles is sent again only once a later run +// completes: other stops (the abort before /compact, say) leave it in pi's +// queue, and a completed run always empties that queue, so by then a copy +// still listed here was lost. Sending it into the next run instead could +// deliver it twice: pi takes one queued steer per turn. // // 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. +// /compact, /new, /resume and /fork -- holds due alarms rather than starting +// a run for them, so stopping the agent is never followed by an unasked-for +// turn, nor by one on a session being torn down. They go once the next run +// the user starts completes; one that falls due meanwhile is steered into +// that run as usual. 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 @@ -36,7 +43,7 @@ // /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). +// agent_start, 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"; @@ -87,7 +94,8 @@ function preamble(a: Alarm): string { "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.", + "limits, then report back to the user. If it arrives while you are in the middle of " + + "another task, weigh the two: deal with it now or after that task, and say which.", ].join("\n"); } @@ -143,7 +151,7 @@ function parseAt(at: string, now: number): number { 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. +const DESCRIPTION = `Set, list or cancel an alarm for yourself. When an alarm fires, your note comes back to you as a message. If you are idle, it starts a turn; if you are working, it is added to the conversation once the tool calls in progress finish, and you decide how it fits with the task at hand. 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}). @@ -166,25 +174,39 @@ const Params = Type.Object({ export default function (pi: ExtensionAPI) { const alarms = new Map<number, Alarm>(); - const due: { details: AlarmDetails; text: string }[] = []; + // Fired and not yet seen in the conversation. sent: handed to pi; resend: + // sent before and not seen by the time that run settled, so pi may still + // hold a copy. + const due: { details: AlarmDetails; text: string; sent: boolean; resend?: boolean }[] = []; let nextId = 1; let session: ExtensionContext | undefined; + // From agent_start to agent_settled. + let running = false; // 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. + // In a run, steer every due alarm in, except a resend (see the top of this + // file). Idle, start a run with the first; agent_start then steers in the + // rest. 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" }, - ); + if (running) { + for (const d of due) if (!d.sent && !d.resend) send(d); + } else if (!held && session?.isIdle()) { + const next = due.find((d) => !d.sent); + if (next) send(next); } } + function send(d: (typeof due)[number]) { + d.sent = true; + pi.sendMessage<AlarmDetails>( + { customType: CUSTOM_TYPE, content: d.text, display: true, details: d.details }, + { triggerTurn: true, deliverAs: "steer" }, + ); + } + function arm(a: Alarm) { a.timer = setTimeout(() => fire(a), Math.max(0, a.nextAt - Date.now())); a.timer.unref?.(); @@ -199,31 +221,37 @@ export default function (pi: ExtensionAPI) { 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); + const entry = { details: { id: a.id, fire: a.fired, fires: a.fires, setAt: a.setAt, note: a.note }, text, sent: false }; + const waiting = due.findIndex((d) => d.details.id === a.id && !d.sent && !d.resend); if (waiting >= 0) due[waiting] = entry; else due.push(entry); deliver(); } + // An alarm already handed to pi cannot be taken back. function cancel(id: number | "all"): string { - const pending = new Set([...alarms.keys(), ...due.map((d) => d.details.id)]); + const pending = new Set([...alarms.keys(), ...due.filter((d) => !d.sent).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)); + const maybe = due.some((d) => d.resend && ids.includes(d.details.id)); + const keep = due.filter((d) => d.sent || !ids.includes(d.details.id)); due.splice(0, due.length, ...keep); - return ids.length ? `Cancelled ${ids.map((i) => `#${i}`).join(", ")}.` : "No pending alarms."; + const caveat = maybe ? " A copy pi already holds from a stopped run may still arrive." : ""; + return ids.length ? `Cancelled ${ids.map((i) => `#${i}`).join(", ")}.${caveat}` : "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 why = (d: (typeof due)[number]) => + d.sent + ? "handed to pi, in after the tool calls in progress" + : held + ? "held because the last run was stopped; it goes once your next run completes" + : "waiting to be delivered"; + const waiting = due.map((d) => `#${d.details.id} due, ${why(d)}: ${d.details.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}`; @@ -331,19 +359,33 @@ export default function (pi: ExtensionAPI) { }); pi.on("agent_start", async () => { + running = true; lastStop = undefined; + held = false; + deliver(); }); + // An alarm is delivered once it is in the conversation. pi.on("message_end", async (event) => { - if (event.message.role === "assistant") lastStop = event.message.stopReason; + const m = event.message; + if (m.role === "assistant") lastStop = m.stopReason; + if (m.role === "custom" && m.customType === CUSTOM_TYPE) { + const d = m.details as AlarmDetails | undefined; + const i = due.findIndex((e) => e.details.id === d?.id && e.details.fire === d?.fire); + if (i >= 0) due.splice(i, 1); + } }); // A stopped run holds due alarms (see the top of this file); a completed one - // releases them. + // delivers them. pi.on("agent_settled", async (_event, ctx) => { + running = false; + for (const d of due) { + if (d.sent) Object.assign(d, { sent: false, resend: true }); + } 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.", + "It goes once your next run completes; /alarms cancel <id> drops it.", "info", ); } |
