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 +1 -1
- package/package.json +1 -1
- package/src/index.test.ts +39 -2
- package/src/index.ts +39 -6
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;
|
|
@@ -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")?.(
|
|
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", (
|
|
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
|
-
|
|
311
|
-
|
|
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();
|