pi-wakeup 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,7 +27,7 @@ Every tmux / UI call is best-effort: a failure in a timer callback is caught and
27
27
 
28
28
  ## Design
29
29
 
30
- - **Session-local.** A wakeup is an in-process `setTimeout`, not a cron job. It lives and dies with the session. This is deliberate: subagents and pacing in pi are in-process (the muster-teammate shape is shelved), and cross-session scheduling is a different product (`cron` + `pi -p`), not this tool.
30
+ - **Session-local.** A wakeup is an in-process `setTimeout`, not a cron job. It lives and dies with the session, and survives `/reload`: the pending wakeups are handed to the reloaded extension, which re-arms each one for the time it had left. This is deliberate: subagents and pacing in pi are in-process (the muster-teammate shape is shelved), and cross-session scheduling is a different product (`cron` + `pi -p`), not this tool.
31
31
  - **Busy-gated delivery.** `agent_start` / `agent_settled` track whether a turn is running; delivery picks `steer` (idle) or `followUp` (busy) accordingly, so a wakeup never interrupts a turn in flight.
32
32
  - **Best-effort.** Every handler and the timer callback swallow-and-log on error — a missed wakeup must never take the session process down. The pure decisions (clamp, delivery mode, message shape) live in `src/schedule.ts` and are unit-tested with zero mocking; `src/index.ts` is the thin pi wiring, in the same split the guardrails package uses.
33
33
  - **Persistence.** Each schedule / fire / cancel is written with `pi.appendEntry` so the session transcript records the wakeup lifecycle.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-wakeup",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "schedule_wakeup / cancel_wakeup for the pi coding agent: session-local self-pacing, the pi analog of Claude Code's ScheduleWakeup dynamic mode.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.test.ts CHANGED
@@ -21,6 +21,9 @@ function harness(
21
21
  // Make a tmux call or setWidget throw, to prove containment.
22
22
  tmuxThrows?: boolean;
23
23
  widgetThrows?: boolean;
24
+ // The session_start event (a reload re-arms handed-over wakeups) and clock.
25
+ startEvent?: unknown;
26
+ nowMs?: number;
24
27
  } = {},
25
28
  ) {
26
29
  const idle = opts.idle ?? true;
@@ -40,6 +43,7 @@ function harness(
40
43
  return idle;
41
44
  },
42
45
  ui: {
46
+ theme: { fg: (color: string, text: string) => `<${color}>${text}` },
43
47
  notify: (text: string, level: string) => notifies.push({ text, level }),
44
48
  setWidget: (key: string, lines: string[] | undefined) => {
45
49
  if (opts.widgetThrows) throw new Error("stale widget ctx");
@@ -73,7 +77,7 @@ function harness(
73
77
  clearTimer: (handle: unknown) => {
74
78
  (handle as Timer).cancelled = true;
75
79
  },
76
- now: () => 1000,
80
+ now: () => opts.nowMs ?? 1000,
77
81
  setInterval: (fn: () => void, ms: number) => {
78
82
  const i: Interval = { fn, ms, cancelled: false };
79
83
  intervals.push(i);
@@ -97,7 +101,7 @@ function harness(
97
101
 
98
102
  createWakeup(pi, deps);
99
103
  // Prime the captured ctx as a real session would via session_start.
100
- handlers.get("session_start")?.(undefined, ctx);
104
+ handlers.get("session_start")?.(opts.startEvent, ctx);
101
105
 
102
106
  const schedule = (params: any) =>
103
107
  tools.get("schedule_wakeup").execute("call", params, undefined, undefined, ctx);
@@ -242,6 +246,38 @@ test("session_shutdown clears every timer and no wakeup can still deliver", asyn
242
246
  assert.equal(h.sent.length, 0);
243
247
  });
244
248
 
249
+ test("a /reload hands pending wakeups to the new copy, which re-arms them for the time left", async () => {
250
+ const before = harness();
251
+ await before.schedule({ delay_seconds: 120, prompt: "a", reason: "ci" });
252
+ await before.schedule({ delay_seconds: 200, prompt: "b" });
253
+ await before.cancel({ id: "wake-2" });
254
+ before.handlers.get("session_shutdown")!({ reason: "reload" });
255
+ assert.ok(before.timers.every((t) => t.cancelled));
256
+
257
+ const after = harness({ startEvent: { reason: "reload" }, nowMs: 31_000 });
258
+ assert.equal(after.timers.length, 1);
259
+ assert.equal(after.timers[0].ms, 90_000, "120s scheduled at t=1s, reloaded at t=31s");
260
+ after.fire(after.timers[0]);
261
+ assert.equal(after.sent.length, 1);
262
+ assert.match(after.sent[0].msg.content, /a/);
263
+ await after.schedule({ delay_seconds: 60, prompt: "c" });
264
+ assert.match(after.entries.at(-1)!.data.id, /wake-3/, "ids continue, not reused");
265
+ });
266
+
267
+ test("a quit hands nothing over, and a fresh start re-arms nothing", async () => {
268
+ const before = harness();
269
+ await before.schedule({ delay_seconds: 120, prompt: "a" });
270
+ before.handlers.get("session_shutdown")!({ reason: "quit" });
271
+ const after = harness({ startEvent: { reason: "reload" } });
272
+ assert.equal(after.timers.length, 0);
273
+
274
+ const reloaded = harness();
275
+ await reloaded.schedule({ delay_seconds: 120, prompt: "a" });
276
+ reloaded.handlers.get("session_shutdown")!({ reason: "reload" });
277
+ const fresh = harness({ startEvent: { reason: "startup" } });
278
+ assert.equal(fresh.timers.length, 0);
279
+ });
280
+
245
281
  // --- Countdown display -----------------------------------------------------
246
282
 
247
283
  test("widget mode: scheduling shows a line above the editor and arms a refresh", async () => {
@@ -252,6 +288,7 @@ test("widget mode: scheduling shows a line above the editor and arms a refresh",
252
288
  assert.equal(last.lines!.length, 1);
253
289
  assert.match(last.lines![0], /2m/); // 120s from now()=1000 -> 2m remaining
254
290
  assert.match(last.lines![0], /CI/);
291
+ assert.match(last.lines![0], /^<warning>⏰ /); // styled like the tmux bar
255
292
  assert.equal(h.intervals.length, 1);
256
293
  assert.equal(h.intervals[0].cancelled, false);
257
294
  // The tmux runner is never touched in widget mode.
package/src/index.ts CHANGED
@@ -18,7 +18,9 @@
18
18
  // pi.appendEntry so the session transcript records the wakeup lifecycle.
19
19
  // Re-arming a pending wakeup across a session RESUME is a documented follow-up
20
20
  // (the extension API exposes no entry read-back). Within a live session,
21
- // wakeups fire.
21
+ // wakeups fire, including across /reload: a reload shuts this extension down
22
+ // and loads a fresh copy in the same process, so shutdown hands the pending
23
+ // wakeups to the new copy through a global, which re-arms them on start.
22
24
 
23
25
  import { execFileSync } from "node:child_process";
24
26
  import {
@@ -33,6 +35,11 @@ import {
33
35
  // how long is left; it's the same value persisted in the wakeup_scheduled entry.
34
36
  type Pending = { prompt: string; reason: string; fireAtMs: number };
35
37
 
38
+ // Where a shutdown for /reload leaves the pending wakeups (and the id counter,
39
+ // so new ids don't collide) for the freshly loaded copy to pick up.
40
+ const HANDOFF_KEY = Symbol.for("pi-wakeup:reload-handoff");
41
+ type Handoff = { counter: number; pending: Array<[string, Pending]> };
42
+
36
43
  // How often the countdown re-renders while anything is pending. The interval is
37
44
  // unref'd so it never holds the process open on its own.
38
45
  const REFRESH_MS = 30_000;
@@ -194,6 +201,19 @@ export function createWakeup(pi: any, deps: Deps = {}): void {
194
201
  }
195
202
  }
196
203
 
204
+ // The widget line matches the tmux bar: a clock and the theme's warning
205
+ // (yellow) colour, so it stands out above the editor. The theme read is
206
+ // guarded like every other ctx.ui access; without one the line is plain.
207
+ function widgetLine(text: string): string {
208
+ const line = `⏰ ${text}`;
209
+ try {
210
+ const theme = ctx?.ui?.theme;
211
+ return theme && typeof theme.fg === "function" ? theme.fg("warning", line) : line;
212
+ } catch {
213
+ return line;
214
+ }
215
+ }
216
+
197
217
  // Render the current pending list to the chosen target, or clear it when
198
218
  // nothing is pending. Runs inside timer/interval callbacks, so every tmux and
199
219
  // ctx.ui call is guarded — a throw here must never escape (best-effort).
@@ -214,7 +234,7 @@ export function createWakeup(pi: any, deps: Deps = {}): void {
214
234
  // Widget mode. Guard ctx.ui: a ctx captured at session_start goes stale
215
235
  // after /reload or a session switch, and setWidget would then throw.
216
236
  if (ctx?.ui?.setWidget) {
217
- ctx.ui.setWidget("wakeup", text === undefined ? undefined : [text]);
237
+ ctx.ui.setWidget("wakeup", text === undefined ? undefined : [widgetLine(text)]);
218
238
  }
219
239
  } catch (error) {
220
240
  log(`display refresh failed: ${String(error)}`);
@@ -303,14 +323,27 @@ export function createWakeup(pi: any, deps: Deps = {}): void {
303
323
  }
304
324
  }
305
325
 
306
- pi.on("session_start", (_event: unknown, sessionCtx: any) => {
326
+ pi.on("session_start", (event: any, sessionCtx: any) => {
307
327
  ctx = sessionCtx;
308
328
  // Decide the display target once, now, from the env + tmux status strings.
309
329
  decideDisplay();
310
- // Nothing is pending at a fresh start, so this just clears any stale display.
311
- refresh();
330
+ const store = globalThis as Record<symbol, Handoff | undefined>;
331
+ const handoff = event?.reason === "reload" ? store[HANDOFF_KEY] : undefined;
332
+ delete store[HANDOFF_KEY];
333
+ if (handoff) {
334
+ counter = handoff.counter;
335
+ for (const [id, w] of handoff.pending) {
336
+ pending.set(id, w);
337
+ timers.set(id, setTimer(() => deliver(id), Math.max(0, w.fireAtMs - now())));
338
+ }
339
+ }
340
+ // Shows the re-armed wakeups, or clears any stale display.
341
+ syncDisplay();
312
342
  });
313
- pi.on("session_shutdown", () => {
343
+ pi.on("session_shutdown", (event: any) => {
344
+ if (event?.reason === "reload") {
345
+ (globalThis as Record<symbol, Handoff | undefined>)[HANDOFF_KEY] = { counter, pending: [...pending] };
346
+ }
314
347
  for (const handle of timers.values()) clearTimer(handle);
315
348
  timers.clear();
316
349
  pending.clear();