pi-wakeup 0.1.0 → 0.2.1

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
@@ -9,6 +9,22 @@ A pi extension giving a running session two tools to pace itself — the pi anal
9
9
 
10
10
  Use it to re-check something without a human poking the session — a build that takes a few minutes, a remote queue, a rate-limit window — rather than busy-waiting.
11
11
 
12
+ ## Countdown
13
+
14
+ While a wakeup is pending, the session shows a live countdown to the soonest one: `17:34 · 12m · <reason>` (local fire time, whole minutes left, a truncated reason). Under a minute reads `<1m`; a missing reason drops that segment; more than one pending appends ` · +N [6m, 11m]`, the minutes left on the next two others (`…` when there are more). It re-renders every 30 s (an unref'd interval) and immediately on schedule / fire / cancel / delivery failure, and clears when nothing is pending or on shutdown.
15
+
16
+ Where it shows is decided once at session start:
17
+
18
+ - **tmux bar** — if this pane is inside tmux (`$TMUX` + `$TMUX_PANE`) *and* the server's global `status-left` / `status-right` already renders `@wakeup`, the countdown is written to the pane-session user option `@wakeup` (muster's `@muster_inbox` pattern) and nothing is drawn in the pi pane. Add this to your tmux `status-right` to render it:
19
+
20
+ ```tmux
21
+ set -g status-right '#{?@wakeup,⏰ #{@wakeup} ,}'
22
+ ```
23
+
24
+ - **above the editor** — otherwise (no tmux, or a config that doesn't render `@wakeup`), one line via `ctx.ui.setWidget("wakeup", …)` above the pi editor.
25
+
26
+ Every tmux / UI call is best-effort: a failure in a timer callback is caught and logged, never thrown.
27
+
12
28
  ## Design
13
29
 
14
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-wakeup",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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
@@ -4,8 +4,25 @@ import { createWakeup } from "./index.ts";
4
4
  import type { Deps } from "./index.ts";
5
5
 
6
6
  type Timer = { fn: () => void; ms: number; cancelled: boolean };
7
+ type Interval = { fn: () => void; ms: number; cancelled: boolean };
8
+ type TmuxCall = { socket: string; args: string[] };
9
+ type WidgetCall = { key: string; lines: string[] | undefined };
7
10
 
8
- function harness(opts: { idle?: boolean; sendThrows?: boolean; idleThrows?: boolean } = {}) {
11
+ function harness(
12
+ opts: {
13
+ idle?: boolean;
14
+ sendThrows?: boolean;
15
+ idleThrows?: boolean;
16
+ // Countdown display knobs. By default no tmux env -> widget mode.
17
+ env?: NodeJS.ProcessEnv;
18
+ // The tmux status-left / status-right the mode decision reads.
19
+ statusLeft?: string;
20
+ statusRight?: string;
21
+ // Make a tmux call or setWidget throw, to prove containment.
22
+ tmuxThrows?: boolean;
23
+ widgetThrows?: boolean;
24
+ } = {},
25
+ ) {
9
26
  const idle = opts.idle ?? true;
10
27
  const tools = new Map<string, any>();
11
28
  const handlers = new Map<string, (event?: unknown, ctx?: unknown) => void>();
@@ -13,13 +30,23 @@ function harness(opts: { idle?: boolean; sendThrows?: boolean; idleThrows?: bool
13
30
  const entries: Array<{ type: string; data: any }> = [];
14
31
  const notifies: Array<{ text: string; level: string }> = [];
15
32
  const timers: Timer[] = [];
33
+ const intervals: Interval[] = [];
34
+ const tmuxCalls: TmuxCall[] = [];
35
+ const widgetCalls: WidgetCall[] = [];
16
36
 
17
37
  const ctx = {
18
38
  isIdle: () => {
19
39
  if (opts.idleThrows) throw new Error("stale ctx");
20
40
  return idle;
21
41
  },
22
- ui: { notify: (text: string, level: string) => notifies.push({ text, level }) },
42
+ ui: {
43
+ theme: { fg: (color: string, text: string) => `<${color}>${text}` },
44
+ notify: (text: string, level: string) => notifies.push({ text, level }),
45
+ setWidget: (key: string, lines: string[] | undefined) => {
46
+ if (opts.widgetThrows) throw new Error("stale widget ctx");
47
+ widgetCalls.push({ key, lines });
48
+ },
49
+ },
23
50
  };
24
51
 
25
52
  const pi = {
@@ -48,6 +75,25 @@ function harness(opts: { idle?: boolean; sendThrows?: boolean; idleThrows?: bool
48
75
  (handle as Timer).cancelled = true;
49
76
  },
50
77
  now: () => 1000,
78
+ setInterval: (fn: () => void, ms: number) => {
79
+ const i: Interval = { fn, ms, cancelled: false };
80
+ intervals.push(i);
81
+ return i;
82
+ },
83
+ clearInterval: (handle: unknown) => {
84
+ (handle as Interval).cancelled = true;
85
+ },
86
+ runTmux: (socket: string, args: string[]) => {
87
+ tmuxCalls.push({ socket, args });
88
+ // Serve the status strings the mode decision reads (these never throw, so
89
+ // the decision can still land on tmux mode even when writes blow up).
90
+ if (args[0] === "show" && args.includes("status-left")) return opts.statusLeft ?? "";
91
+ if (args[0] === "show" && args.includes("status-right")) return opts.statusRight ?? "";
92
+ // A write (set-option) is where a flaky tmux surfaces.
93
+ if (opts.tmuxThrows) throw new Error("tmux boom");
94
+ return "";
95
+ },
96
+ env: opts.env ?? {},
51
97
  };
52
98
 
53
99
  createWakeup(pi, deps);
@@ -60,9 +106,28 @@ function harness(opts: { idle?: boolean; sendThrows?: boolean; idleThrows?: bool
60
106
  const fire = (t: Timer) => {
61
107
  if (!t.cancelled) t.fn();
62
108
  };
63
- return { tools, handlers, sent, entries, notifies, timers, schedule, cancel, fire };
109
+ const tmuxSets = () =>
110
+ tmuxCalls.filter((c) => c.args[0] === "set-option");
111
+ return {
112
+ tools,
113
+ handlers,
114
+ sent,
115
+ entries,
116
+ notifies,
117
+ timers,
118
+ intervals,
119
+ tmuxCalls,
120
+ tmuxSets,
121
+ widgetCalls,
122
+ schedule,
123
+ cancel,
124
+ fire,
125
+ };
64
126
  }
65
127
 
128
+ // A tmux env whose status line renders @wakeup -> the extension uses the bar.
129
+ const TMUX_ENV = { TMUX: "/tmp/sock,42,0", TMUX_PANE: "%7" };
130
+
66
131
  test("registers both tools", () => {
67
132
  const h = harness();
68
133
  assert.ok(h.tools.has("schedule_wakeup"));
@@ -177,3 +242,94 @@ test("session_shutdown clears every timer and no wakeup can still deliver", asyn
177
242
  h.timers.forEach((t) => t.fn());
178
243
  assert.equal(h.sent.length, 0);
179
244
  });
245
+
246
+ // --- Countdown display -----------------------------------------------------
247
+
248
+ test("widget mode: scheduling shows a line above the editor and arms a refresh", async () => {
249
+ const h = harness(); // no tmux env -> widget
250
+ await h.schedule({ delay_seconds: 120, prompt: "x", reason: "CI" });
251
+ const last = h.widgetCalls.at(-1)!;
252
+ assert.equal(last.key, "wakeup");
253
+ assert.equal(last.lines!.length, 1);
254
+ assert.match(last.lines![0], /2m/); // 120s from now()=1000 -> 2m remaining
255
+ assert.match(last.lines![0], /CI/);
256
+ assert.match(last.lines![0], /^<warning>⏰ /); // styled like the tmux bar
257
+ assert.equal(h.intervals.length, 1);
258
+ assert.equal(h.intervals[0].cancelled, false);
259
+ // The tmux runner is never touched in widget mode.
260
+ assert.equal(h.tmuxSets().length, 0);
261
+ });
262
+
263
+ test("widget mode: a refresh tick re-renders while pending", async () => {
264
+ const h = harness();
265
+ await h.schedule({ delay_seconds: 120, prompt: "x" });
266
+ const before = h.widgetCalls.length;
267
+ h.intervals[0].fn(); // simulate a 30s tick
268
+ assert.ok(h.widgetCalls.length > before);
269
+ });
270
+
271
+ test("widget mode: cancelling the last wakeup clears the widget and stops the refresh", async () => {
272
+ const h = harness();
273
+ const res = await h.schedule({ delay_seconds: 120, prompt: "x" });
274
+ await h.cancel({ id: res.details.id });
275
+ const last = h.widgetCalls.at(-1)!;
276
+ assert.equal(last.lines, undefined); // cleared
277
+ assert.equal(h.intervals[0].cancelled, true);
278
+ });
279
+
280
+ test("widget mode: firing clears the display when nothing else is pending", async () => {
281
+ const h = harness({ idle: true });
282
+ await h.schedule({ delay_seconds: 120, prompt: "x" });
283
+ h.fire(h.timers[0]);
284
+ assert.equal(h.widgetCalls.at(-1)!.lines, undefined);
285
+ assert.equal(h.intervals[0].cancelled, true);
286
+ });
287
+
288
+ test("tmux mode: sets @wakeup on the pane session and never draws a widget", async () => {
289
+ const h = harness({ env: TMUX_ENV, statusRight: "#{?@wakeup,x,}" });
290
+ await h.schedule({ delay_seconds: 120, prompt: "x", reason: "CI" });
291
+ const set = h.tmuxSets().at(-1)!;
292
+ assert.deepEqual(set.args.slice(0, 4), ["set-option", "-t", "%7", "@wakeup"]);
293
+ assert.match(set.args[4], /2m/);
294
+ assert.equal(set.socket, "sock"); // basename of /tmp/sock
295
+ assert.equal(h.widgetCalls.length, 0); // nothing in the pi pane
296
+ });
297
+
298
+ test("tmux mode: cancelling the last wakeup unsets @wakeup", async () => {
299
+ const h = harness({ env: TMUX_ENV, statusRight: "#{?@wakeup,x,}" });
300
+ await h.cancel({}); // nothing pending -> no-op, but prove the unset path on real cancel
301
+ const res = await h.schedule({ delay_seconds: 120, prompt: "x" });
302
+ await h.cancel({ id: res.details.id });
303
+ const set = h.tmuxSets().at(-1)!;
304
+ assert.deepEqual(set.args, ["set-option", "-u", "-t", "%7", "@wakeup"]);
305
+ assert.equal(h.widgetCalls.length, 0);
306
+ });
307
+
308
+ test("tmux mode is NOT used when the status line doesn't render @wakeup", async () => {
309
+ const h = harness({ env: TMUX_ENV, statusRight: "plain bar", statusLeft: "also plain" });
310
+ await h.schedule({ delay_seconds: 120, prompt: "x" });
311
+ // Falls back to the widget; no @wakeup option set.
312
+ assert.ok(h.widgetCalls.length > 0);
313
+ assert.equal(h.tmuxSets().length, 0);
314
+ });
315
+
316
+ test("tmux mode: a throwing set-option is contained and does not fail the schedule", async () => {
317
+ // Decision lands on tmux (show calls succeed); the set-option write throws.
318
+ const h = harness({ env: TMUX_ENV, statusRight: "#{?@wakeup,x,}", tmuxThrows: true });
319
+ await assert.doesNotReject(() => h.schedule({ delay_seconds: 120, prompt: "x" }));
320
+ assert.ok(h.tmuxSets().length >= 1); // it tried the write
321
+ assert.equal(h.widgetCalls.length, 0); // still never drew a widget
322
+ });
323
+
324
+ test("a throwing setWidget is contained and does not fail the schedule", async () => {
325
+ const h = harness({ widgetThrows: true });
326
+ await assert.doesNotReject(() => h.schedule({ delay_seconds: 120, prompt: "x" }));
327
+ });
328
+
329
+ test("session_shutdown clears the display and stops the refresh interval", async () => {
330
+ const h = harness();
331
+ await h.schedule({ delay_seconds: 120, prompt: "x" });
332
+ h.handlers.get("session_shutdown")!();
333
+ assert.equal(h.widgetCalls.at(-1)!.lines, undefined); // cleared
334
+ assert.equal(h.intervals[0].cancelled, true);
335
+ });
package/src/index.ts CHANGED
@@ -20,13 +20,22 @@
20
20
  // (the extension API exposes no entry read-back). Within a live session,
21
21
  // wakeups fire.
22
22
 
23
+ import { execFileSync } from "node:child_process";
23
24
  import {
24
25
  clampDelaySeconds,
26
+ decideDisplayMode,
25
27
  deliveryMode,
28
+ formatCountdown,
26
29
  wakeupMessage,
27
30
  } from "./schedule.ts";
28
31
 
29
- type Pending = { prompt: string; reason: string };
32
+ // fireAtMs is tracked per pending wakeup so the countdown display can derive
33
+ // how long is left; it's the same value persisted in the wakeup_scheduled entry.
34
+ type Pending = { prompt: string; reason: string; fireAtMs: number };
35
+
36
+ // How often the countdown re-renders while anything is pending. The interval is
37
+ // unref'd so it never holds the process open on its own.
38
+ const REFRESH_MS = 30_000;
30
39
 
31
40
  export type Deps = {
32
41
  // Injectable so the wiring is testable without real time passing. Default to
@@ -34,6 +43,14 @@ export type Deps = {
34
43
  setTimer?: (fn: () => void, ms: number) => unknown;
35
44
  clearTimer?: (handle: unknown) => void;
36
45
  now?: () => number;
46
+ // The countdown's periodic refresh, also injectable so tests can drive it.
47
+ setInterval?: (fn: () => void, ms: number) => unknown;
48
+ clearInterval?: (handle: unknown) => void;
49
+ // The tmux runner, mirroring pi-tmux-bridge's `tmux()`: best-effort, returns
50
+ // the trimmed stdout or undefined. Injected so the tmux-bar path is testable.
51
+ runTmux?: (socket: string, args: string[]) => string | undefined;
52
+ // The environment the tmux mode decision reads ($TMUX / $TMUX_PANE).
53
+ env?: NodeJS.ProcessEnv;
37
54
  };
38
55
 
39
56
  const SCHEDULE_PARAMS = {
@@ -80,6 +97,34 @@ export function createWakeup(pi: any, deps: Deps = {}): void {
80
97
  const clearTimer =
81
98
  deps.clearTimer ?? ((handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>));
82
99
  const now = deps.now ?? (() => Date.now());
100
+ const setIntervalFn =
101
+ deps.setInterval ??
102
+ ((fn: () => void, ms: number) => {
103
+ const handle = setInterval(fn, ms);
104
+ // Unref'd like the per-wakeup timer: the refresh loop is cosmetic and must
105
+ // never be the reason the process stays alive.
106
+ const unrefable = handle as { unref?: () => void };
107
+ if (typeof unrefable.unref === "function") unrefable.unref();
108
+ return handle;
109
+ });
110
+ const clearIntervalFn =
111
+ deps.clearInterval ??
112
+ ((handle: unknown) => clearInterval(handle as ReturnType<typeof setInterval>));
113
+ const runTmux =
114
+ deps.runTmux ??
115
+ ((socket: string, args: string[]) => {
116
+ // Best-effort, mirroring pi-tmux-bridge's tmux(): a display call must never
117
+ // fail a tool call, a timer callback, or a shutdown.
118
+ try {
119
+ return execFileSync("tmux", ["-L", socket, ...args], {
120
+ encoding: "utf-8",
121
+ stdio: ["ignore", "pipe", "ignore"],
122
+ }).trim();
123
+ } catch {
124
+ return undefined;
125
+ }
126
+ });
127
+ const env = deps.env ?? process.env;
83
128
 
84
129
  const pending = new Map<string, Pending>();
85
130
  const timers = new Map<string, unknown>();
@@ -88,6 +133,14 @@ export function createWakeup(pi: any, deps: Deps = {}): void {
88
133
  let ctx: any;
89
134
  let counter = 0;
90
135
 
136
+ // Countdown display state, decided ONCE at session_start. In tmux mode we set
137
+ // a pane-session option the status line renders; otherwise we draw a widget
138
+ // above the pi editor. intervalHandle re-renders the minutes ticking down.
139
+ let displayTarget: "tmux" | "widget" = "widget";
140
+ let tmuxSocket: string | undefined;
141
+ let tmuxPane: string | undefined;
142
+ let intervalHandle: unknown;
143
+
91
144
  function log(message: string): void {
92
145
  try {
93
146
  process.stderr.write(`[wakeup] ${message}\n`);
@@ -96,6 +149,117 @@ export function createWakeup(pi: any, deps: Deps = {}): void {
96
149
  }
97
150
  }
98
151
 
152
+ // Local HH:MM in the machine's timezone — the fire time a human reads off
153
+ // the bar. Kept here (not in schedule.ts) so the pure formatter stays TZ-free.
154
+ function formatClock(ms: number): string {
155
+ const d = new Date(ms);
156
+ const hh = String(d.getHours()).padStart(2, "0");
157
+ const mm = String(d.getMinutes()).padStart(2, "0");
158
+ return `${hh}:${mm}`;
159
+ }
160
+
161
+ // $TMUX is "<socket-path>,<pid>,<session>"; the socket NAME is the basename of
162
+ // the first field, exactly as pi-tmux-bridge resolves it. undefined unless
163
+ // both $TMUX and $TMUX_PANE are set.
164
+ function resolveTmuxCtx(): { socket: string; pane: string } | undefined {
165
+ const tmuxEnv = env.TMUX;
166
+ const pane = env.TMUX_PANE;
167
+ if (!tmuxEnv || !pane) return undefined;
168
+ const socketPath = tmuxEnv.split(",")[0];
169
+ if (!socketPath) return undefined;
170
+ const socket = socketPath.slice(socketPath.lastIndexOf("/") + 1);
171
+ if (!socket) return undefined;
172
+ return { socket, pane };
173
+ }
174
+
175
+ // Decide where the countdown shows, ONCE, from the env and the tmux server's
176
+ // global status strings. Best-effort: any failure falls back to the widget.
177
+ function decideDisplay(): void {
178
+ try {
179
+ const t = resolveTmuxCtx();
180
+ if (!t) {
181
+ displayTarget = "widget";
182
+ return;
183
+ }
184
+ const left = runTmux(t.socket, ["show", "-gv", "status-left"]);
185
+ const right = runTmux(t.socket, ["show", "-gv", "status-right"]);
186
+ displayTarget = decideDisplayMode(env, left, right);
187
+ if (displayTarget === "tmux") {
188
+ tmuxSocket = t.socket;
189
+ tmuxPane = t.pane;
190
+ }
191
+ } catch (error) {
192
+ displayTarget = "widget";
193
+ log(`display mode decision failed: ${String(error)}`);
194
+ }
195
+ }
196
+
197
+ // The widget line matches the tmux bar: a clock and the theme's warning
198
+ // (yellow) colour, so it stands out above the editor. The theme read is
199
+ // guarded like every other ctx.ui access; without one the line is plain.
200
+ function widgetLine(text: string): string {
201
+ const line = `⏰ ${text}`;
202
+ try {
203
+ const theme = ctx?.ui?.theme;
204
+ return theme && typeof theme.fg === "function" ? theme.fg("warning", line) : line;
205
+ } catch {
206
+ return line;
207
+ }
208
+ }
209
+
210
+ // Render the current pending list to the chosen target, or clear it when
211
+ // nothing is pending. Runs inside timer/interval callbacks, so every tmux and
212
+ // ctx.ui call is guarded — a throw here must never escape (best-effort).
213
+ function refresh(): void {
214
+ const view = [...pending.values()].map((p) => ({ fireAtMs: p.fireAtMs, reason: p.reason }));
215
+ const text = formatCountdown(view, now(), formatClock);
216
+ try {
217
+ if (displayTarget === "tmux" && tmuxSocket && tmuxPane) {
218
+ // Follows muster's @muster_inbox pattern: set the user option on THIS
219
+ // pane's session; `-u` unsets it so the status segment collapses.
220
+ if (text === undefined) {
221
+ runTmux(tmuxSocket, ["set-option", "-u", "-t", tmuxPane, "@wakeup"]);
222
+ } else {
223
+ runTmux(tmuxSocket, ["set-option", "-t", tmuxPane, "@wakeup", text]);
224
+ }
225
+ return;
226
+ }
227
+ // Widget mode. Guard ctx.ui: a ctx captured at session_start goes stale
228
+ // after /reload or a session switch, and setWidget would then throw.
229
+ if (ctx?.ui?.setWidget) {
230
+ ctx.ui.setWidget("wakeup", text === undefined ? undefined : [widgetLine(text)]);
231
+ }
232
+ } catch (error) {
233
+ log(`display refresh failed: ${String(error)}`);
234
+ }
235
+ }
236
+
237
+ // Re-render now and keep the refresh interval in step with the pending set:
238
+ // start it when something is pending, stop it (and clear the display implied
239
+ // by an empty set) when nothing is.
240
+ function syncDisplay(): void {
241
+ refresh();
242
+ if (pending.size === 0) {
243
+ stopInterval();
244
+ } else if (intervalHandle === undefined) {
245
+ intervalHandle = setIntervalFn(() => {
246
+ try {
247
+ refresh();
248
+ } catch {
249
+ // refresh is already guarded; this is belt-and-braces for the callback.
250
+ }
251
+ if (pending.size === 0) stopInterval();
252
+ }, REFRESH_MS);
253
+ }
254
+ }
255
+
256
+ function stopInterval(): void {
257
+ if (intervalHandle !== undefined) {
258
+ clearIntervalFn(intervalHandle);
259
+ intervalHandle = undefined;
260
+ }
261
+ }
262
+
99
263
  function forget(id: string): void {
100
264
  const handle = timers.get(id);
101
265
  if (handle !== undefined) clearTimer(handle);
@@ -107,8 +271,9 @@ export function createWakeup(pi: any, deps: Deps = {}): void {
107
271
  const w = pending.get(id);
108
272
  if (!w) return;
109
273
  // Remove BEFORE delivering: one-shot, and a throw in sendMessage must not
110
- // leave a half-fired wakeup armed.
274
+ // leave a half-fired wakeup armed. Refresh the countdown off the firing.
111
275
  forget(id);
276
+ syncDisplay();
112
277
  // Reading ctx.isIdle() can THROW: a ctx captured at session_start goes
113
278
  // stale after /reload, fork, or a session switch (assertActive() throws),
114
279
  // and this runs up to an hour later inside a setTimeout callback where an
@@ -153,11 +318,19 @@ export function createWakeup(pi: any, deps: Deps = {}): void {
153
318
 
154
319
  pi.on("session_start", (_event: unknown, sessionCtx: any) => {
155
320
  ctx = sessionCtx;
321
+ // Decide the display target once, now, from the env + tmux status strings.
322
+ decideDisplay();
323
+ // Nothing is pending at a fresh start, so this just clears any stale display.
324
+ refresh();
156
325
  });
157
326
  pi.on("session_shutdown", () => {
158
327
  for (const handle of timers.values()) clearTimer(handle);
159
328
  timers.clear();
160
329
  pending.clear();
330
+ stopInterval();
331
+ // Clear the display: with pending now empty, refresh() unsets the option /
332
+ // removes the widget.
333
+ refresh();
161
334
  });
162
335
 
163
336
  pi.registerTool({
@@ -181,7 +354,7 @@ export function createWakeup(pi: any, deps: Deps = {}): void {
181
354
  counter += 1;
182
355
  const id = `wake-${counter}`;
183
356
  const fireAtMs = now() + delay * 1000;
184
- pending.set(id, { prompt, reason });
357
+ pending.set(id, { prompt, reason, fireAtMs });
185
358
  const handle = setTimer(() => deliver(id), delay * 1000);
186
359
  timers.set(id, handle);
187
360
  try {
@@ -189,6 +362,8 @@ export function createWakeup(pi: any, deps: Deps = {}): void {
189
362
  } catch {
190
363
  // best-effort persistence
191
364
  }
365
+ // Show the new wakeup and arm the refresh loop.
366
+ syncDisplay();
192
367
  const reasonText = reason ? ` (${reason})` : "";
193
368
  return {
194
369
  content: [{ type: "text", text: `Scheduled ${id}: waking in ${delay}s${reasonText}.` }],
@@ -214,6 +389,8 @@ export function createWakeup(pi: any, deps: Deps = {}): void {
214
389
  } catch {
215
390
  // best-effort persistence
216
391
  }
392
+ // Update (or clear) the countdown now that one is gone.
393
+ syncDisplay();
217
394
  }
218
395
  return {
219
396
  content: [
@@ -229,6 +406,8 @@ export function createWakeup(pi: any, deps: Deps = {}): void {
229
406
  } catch {
230
407
  // best-effort persistence
231
408
  }
409
+ // Everything's gone: clear the display and stop the refresh loop.
410
+ syncDisplay();
232
411
  }
233
412
  return { content: [{ type: "text", text: `Cancelled ${count} pending wakeup${count === 1 ? "" : "s"}.` }] };
234
413
  },
@@ -2,8 +2,13 @@ import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import {
4
4
  clampDelaySeconds,
5
+ decideDisplayMode,
5
6
  deliveryMode,
7
+ formatCountdown,
8
+ formatRemaining,
9
+ truncateReason,
6
10
  wakeupMessage,
11
+ MAX_REASON_LENGTH,
7
12
  MIN_DELAY_SECONDS,
8
13
  MAX_DELAY_SECONDS,
9
14
  } from "./schedule.ts";
@@ -52,3 +57,77 @@ test("wakeupMessage: an empty or whitespace reason leaves the prompt bare", () =
52
57
  assert.equal(wakeupMessage("check the build", ""), "check the build");
53
58
  assert.equal(wakeupMessage("check the build", " "), "check the build");
54
59
  });
60
+
61
+ // A fixed HH:MM formatter so these assertions never depend on the machine's TZ.
62
+ const clock = (ms: number) => (ms === 0 ? "17:34" : `t+${ms}`);
63
+
64
+ test("formatRemaining: whole minutes, and under a minute collapses to <1m", () => {
65
+ assert.equal(formatRemaining(12 * 60_000), "12m");
66
+ assert.equal(formatRemaining(12 * 60_000 + 59_000), "12m"); // floored
67
+ assert.equal(formatRemaining(59_000), "<1m");
68
+ assert.equal(formatRemaining(0), "<1m");
69
+ assert.equal(formatRemaining(-5000), "<1m"); // a refresh racing the fire
70
+ });
71
+
72
+ test("truncateReason: long reasons are cut with an ellipsis, short ones pass through", () => {
73
+ assert.equal(truncateReason("waiting on CI"), "waiting on CI");
74
+ assert.equal(truncateReason(" waiting on CI "), "waiting on CI"); // trimmed
75
+ const long = "a".repeat(40);
76
+ const out = truncateReason(long);
77
+ assert.equal(out.length, MAX_REASON_LENGTH);
78
+ assert.ok(out.endsWith("\u2026"));
79
+ });
80
+
81
+ test("formatCountdown: nothing pending clears the display", () => {
82
+ assert.equal(formatCountdown([], 0, clock), undefined);
83
+ });
84
+
85
+ test("formatCountdown: soonest wakeup as time \u00b7 minutes \u00b7 reason", () => {
86
+ const text = formatCountdown([{ fireAtMs: 0, reason: "waiting on CI" }], -12 * 60_000, clock);
87
+ assert.equal(text, "17:34 \u00b7 12m \u00b7 waiting on CI");
88
+ });
89
+
90
+ test("formatCountdown: no reason omits that segment", () => {
91
+ const text = formatCountdown([{ fireAtMs: 0, reason: "" }], -12 * 60_000, clock);
92
+ assert.equal(text, "17:34 \u00b7 12m");
93
+ });
94
+
95
+ test("formatCountdown: more pending appends +N with the others' times, soonest first", () => {
96
+ const pending = [
97
+ { fireAtMs: 300_000, reason: "later" },
98
+ { fireAtMs: 0, reason: "soon" },
99
+ { fireAtMs: 600_000, reason: "latest" },
100
+ ];
101
+ const text = formatCountdown(pending, -60_000, clock);
102
+ // Soonest (fireAtMs 0) drives the line; the two others become +2 with their times.
103
+ assert.equal(text, "17:34 \u00b7 1m \u00b7 soon \u00b7 +2 [6m, 11m]");
104
+ });
105
+
106
+ test("formatCountdown: the others' times are capped, the rest shown as \u2026", () => {
107
+ const pending = [0, 300_000, 600_000, 900_000, 1_200_000].map((fireAtMs) => ({ fireAtMs, reason: "" }));
108
+ const text = formatCountdown(pending, -60_000, clock);
109
+ assert.equal(text, "17:34 \u00b7 1m \u00b7 +4 [6m, 11m, \u2026]");
110
+ });
111
+
112
+ test("formatCountdown: a long reason is truncated in the line", () => {
113
+ const text = formatCountdown(
114
+ [{ fireAtMs: 0, reason: "x".repeat(40) }],
115
+ -5 * 60_000,
116
+ clock,
117
+ );
118
+ assert.ok(text!.includes("\u2026"));
119
+ });
120
+
121
+ test("decideDisplayMode: tmux only when in a pane AND the status renders @wakeup", () => {
122
+ const inPane = { TMUX: "/tmp/sock,1,0", TMUX_PANE: "%3" };
123
+ assert.equal(decideDisplayMode(inPane, "", "#{?@wakeup,x,}"), "tmux"); // status-right
124
+ assert.equal(decideDisplayMode(inPane, "@wakeup", ""), "tmux"); // status-left
125
+ assert.equal(decideDisplayMode(inPane, "", ""), "widget"); // config doesn't render it
126
+ assert.equal(decideDisplayMode(inPane, undefined, undefined), "widget");
127
+ });
128
+
129
+ test("decideDisplayMode: no tmux env is always the widget", () => {
130
+ assert.equal(decideDisplayMode({}, "@wakeup", "@wakeup"), "widget");
131
+ assert.equal(decideDisplayMode({ TMUX: "/tmp/sock,1,0" }, "@wakeup", "@wakeup"), "widget");
132
+ assert.equal(decideDisplayMode({ TMUX_PANE: "%3" }, "@wakeup", "@wakeup"), "widget");
133
+ });
package/src/schedule.ts CHANGED
@@ -39,3 +39,84 @@ export function wakeupMessage(prompt: string, reason: string): string {
39
39
  const suffix = trimmedReason ? `\n\n(scheduled wakeup — ${trimmedReason})` : "";
40
40
  return `${prompt}${suffix}`;
41
41
  }
42
+
43
+ // --- Countdown display -----------------------------------------------------
44
+ // The session's pending wakeups, shown with a live countdown so a human can
45
+ // see what the session is waiting on and when it restarts. Pure here (text +
46
+ // mode decision); the I/O — reading tmux, writing a widget or a tmux option —
47
+ // lives in index.ts.
48
+
49
+ // A pending wakeup, reduced to just what the countdown needs: when it fires and
50
+ // why. index.ts carries the full record (prompt, timer handle); this is the
51
+ // view the formatter sees.
52
+ export type PendingView = { fireAtMs: number; reason: string };
53
+
54
+ // Reasons are shown inline in a status bar, so a long one is truncated to keep
55
+ // the line short; the ellipsis marks the cut.
56
+ export const MAX_REASON_LENGTH = 25;
57
+
58
+ export function truncateReason(reason: string, max = MAX_REASON_LENGTH): string {
59
+ const trimmed = typeof reason === "string" ? reason.trim() : "";
60
+ if (trimmed.length <= max) return trimmed;
61
+ // Reserve one char for the ellipsis so the result is exactly `max` wide.
62
+ return `${trimmed.slice(0, max - 1)}…`;
63
+ }
64
+
65
+ // Whole minutes remaining until a fire time. Anything under a minute (including
66
+ // a fire time already past, if a refresh races the timer) collapses to `<1m`
67
+ // rather than showing `0m`.
68
+ export function formatRemaining(remainingMs: number): string {
69
+ if (remainingMs < 60_000) return "<1m";
70
+ return `${Math.floor(remainingMs / 60_000)}m`;
71
+ }
72
+
73
+ // The countdown line for the soonest pending wakeup:
74
+ // `17:34 · 12m · <reason>` (fire time, minutes remaining, truncated reason)
75
+ // A missing reason drops that segment; more than one pending appends
76
+ // ` · +N [6m, 11m]`, the others' minutes left, capped at MAX_OTHER_TIMES with
77
+ // `…` for the rest.
78
+ // Returns undefined when nothing is pending, so the caller clears the display.
79
+ // The HH:MM formatter is injected so tests need not depend on the machine's TZ.
80
+ export function formatCountdown(
81
+ pending: PendingView[],
82
+ nowMs: number,
83
+ formatHHMM: (ms: number) => string,
84
+ ): string | undefined {
85
+ if (pending.length === 0) return undefined;
86
+ // Soonest fire time first — that's the one we count down to.
87
+ const sorted = [...pending].sort((a, b) => a.fireAtMs - b.fireAtMs);
88
+ const soonest = sorted[0];
89
+ const parts = [formatHHMM(soonest.fireAtMs), formatRemaining(soonest.fireAtMs - nowMs)];
90
+ const reason = truncateReason(soonest.reason ?? "");
91
+ if (reason) parts.push(reason);
92
+ let text = parts.join(" · ");
93
+ const others = sorted.slice(1);
94
+ if (others.length > 0) {
95
+ const times = others.slice(0, MAX_OTHER_TIMES).map((w) => formatRemaining(w.fireAtMs - nowMs));
96
+ if (others.length > MAX_OTHER_TIMES) times.push("…");
97
+ text += ` · +${others.length} [${times.join(", ")}]`;
98
+ }
99
+ return text;
100
+ }
101
+
102
+ // How many of the other pending wakeups list their time after `+N`; two keeps
103
+ // the status bar to one short line.
104
+ export const MAX_OTHER_TIMES = 2;
105
+
106
+ // Where the countdown shows, decided ONCE at session_start. The tmux bar is
107
+ // used only when this pane is inside tmux AND the server's global status line
108
+ // actually renders `@wakeup` (otherwise the option we set would go nowhere);
109
+ // everything else falls back to a widget above the pi editor.
110
+ export type DisplayMode = "tmux" | "widget";
111
+
112
+ export function decideDisplayMode(
113
+ env: { TMUX?: string; TMUX_PANE?: string },
114
+ statusLeft: string | undefined,
115
+ statusRight: string | undefined,
116
+ ): DisplayMode {
117
+ if (!env.TMUX || !env.TMUX_PANE) return "widget";
118
+ const left = statusLeft ?? "";
119
+ const right = statusRight ?? "";
120
+ if (left.includes("@wakeup") || right.includes("@wakeup")) return "tmux";
121
+ return "widget";
122
+ }