summaryrefslogtreecommitdiff
path: root/config/pi/extensions/alarm.ts
blob: 182abe0c1af06bc2ef9ba8ec2c59edec258b90e7 (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
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
// 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. 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 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 -- 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
// 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,
// 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";
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. 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");
}

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. 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}).

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>();
  // 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;

  // 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() {
    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?.();
  }

  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, 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.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 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);
    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 = (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}`;
    });
    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 () => {
    running = true;
    lastStop = undefined;
    held = false;
    deliver();
  });
  // An alarm is delivered once it is in the conversation.
  pi.on("message_end", async (event) => {
    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
  // 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 once your 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");
  });
}