summaryrefslogtreecommitdiff
path: root/config/pi/extensions/alarm.ts
blob: c599e4357fba3fb4ba981f477e54dca86460275a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
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");
  });
}