pi-wakeup 0.2.1 → 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.1",
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;
@@ -74,7 +77,7 @@ function harness(
74
77
  clearTimer: (handle: unknown) => {
75
78
  (handle as Timer).cancelled = true;
76
79
  },
77
- now: () => 1000,
80
+ now: () => opts.nowMs ?? 1000,
78
81
  setInterval: (fn: () => void, ms: number) => {
79
82
  const i: Interval = { fn, ms, cancelled: false };
80
83
  intervals.push(i);
@@ -98,7 +101,7 @@ function harness(
98
101
 
99
102
  createWakeup(pi, deps);
100
103
  // Prime the captured ctx as a real session would via session_start.
101
- handlers.get("session_start")?.(undefined, ctx);
104
+ handlers.get("session_start")?.(opts.startEvent, ctx);
102
105
 
103
106
  const schedule = (params: any) =>
104
107
  tools.get("schedule_wakeup").execute("call", params, undefined, undefined, ctx);
@@ -243,6 +246,38 @@ test("session_shutdown clears every timer and no wakeup can still deliver", asyn
243
246
  assert.equal(h.sent.length, 0);
244
247
  });
245
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
+
246
281
  // --- Countdown display -----------------------------------------------------
247
282
 
248
283
  test("widget mode: scheduling shows a line above the editor and arms a refresh", async () => {
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;
@@ -316,14 +323,27 @@ export function createWakeup(pi: any, deps: Deps = {}): void {
316
323
  }
317
324
  }
318
325
 
319
- pi.on("session_start", (_event: unknown, sessionCtx: any) => {
326
+ pi.on("session_start", (event: any, sessionCtx: any) => {
320
327
  ctx = sessionCtx;
321
328
  // Decide the display target once, now, from the env + tmux status strings.
322
329
  decideDisplay();
323
- // Nothing is pending at a fresh start, so this just clears any stale display.
324
- 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();
325
342
  });
326
- 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
+ }
327
347
  for (const handle of timers.values()) clearTimer(handle);
328
348
  timers.clear();
329
349
  pending.clear();