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 +1 -1
- package/package.json +1 -1
- package/src/index.test.ts +37 -2
- package/src/index.ts +25 -5
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.
|
|
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")?.(
|
|
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", (
|
|
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
|
-
|
|
324
|
-
|
|
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();
|