pi-wakeup 0.1.0 → 0.2.0
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 +16 -0
- package/package.json +1 -1
- package/src/index.test.ts +157 -3
- package/src/index.ts +169 -3
- package/src/schedule.test.ts +79 -0
- package/src/schedule.ts +81 -0
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.
|
|
3
|
+
"version": "0.2.0",
|
|
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(
|
|
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,22 @@ 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: {
|
|
42
|
+
ui: {
|
|
43
|
+
notify: (text: string, level: string) => notifies.push({ text, level }),
|
|
44
|
+
setWidget: (key: string, lines: string[] | undefined) => {
|
|
45
|
+
if (opts.widgetThrows) throw new Error("stale widget ctx");
|
|
46
|
+
widgetCalls.push({ key, lines });
|
|
47
|
+
},
|
|
48
|
+
},
|
|
23
49
|
};
|
|
24
50
|
|
|
25
51
|
const pi = {
|
|
@@ -48,6 +74,25 @@ function harness(opts: { idle?: boolean; sendThrows?: boolean; idleThrows?: bool
|
|
|
48
74
|
(handle as Timer).cancelled = true;
|
|
49
75
|
},
|
|
50
76
|
now: () => 1000,
|
|
77
|
+
setInterval: (fn: () => void, ms: number) => {
|
|
78
|
+
const i: Interval = { fn, ms, cancelled: false };
|
|
79
|
+
intervals.push(i);
|
|
80
|
+
return i;
|
|
81
|
+
},
|
|
82
|
+
clearInterval: (handle: unknown) => {
|
|
83
|
+
(handle as Interval).cancelled = true;
|
|
84
|
+
},
|
|
85
|
+
runTmux: (socket: string, args: string[]) => {
|
|
86
|
+
tmuxCalls.push({ socket, args });
|
|
87
|
+
// Serve the status strings the mode decision reads (these never throw, so
|
|
88
|
+
// the decision can still land on tmux mode even when writes blow up).
|
|
89
|
+
if (args[0] === "show" && args.includes("status-left")) return opts.statusLeft ?? "";
|
|
90
|
+
if (args[0] === "show" && args.includes("status-right")) return opts.statusRight ?? "";
|
|
91
|
+
// A write (set-option) is where a flaky tmux surfaces.
|
|
92
|
+
if (opts.tmuxThrows) throw new Error("tmux boom");
|
|
93
|
+
return "";
|
|
94
|
+
},
|
|
95
|
+
env: opts.env ?? {},
|
|
51
96
|
};
|
|
52
97
|
|
|
53
98
|
createWakeup(pi, deps);
|
|
@@ -60,9 +105,28 @@ function harness(opts: { idle?: boolean; sendThrows?: boolean; idleThrows?: bool
|
|
|
60
105
|
const fire = (t: Timer) => {
|
|
61
106
|
if (!t.cancelled) t.fn();
|
|
62
107
|
};
|
|
63
|
-
|
|
108
|
+
const tmuxSets = () =>
|
|
109
|
+
tmuxCalls.filter((c) => c.args[0] === "set-option");
|
|
110
|
+
return {
|
|
111
|
+
tools,
|
|
112
|
+
handlers,
|
|
113
|
+
sent,
|
|
114
|
+
entries,
|
|
115
|
+
notifies,
|
|
116
|
+
timers,
|
|
117
|
+
intervals,
|
|
118
|
+
tmuxCalls,
|
|
119
|
+
tmuxSets,
|
|
120
|
+
widgetCalls,
|
|
121
|
+
schedule,
|
|
122
|
+
cancel,
|
|
123
|
+
fire,
|
|
124
|
+
};
|
|
64
125
|
}
|
|
65
126
|
|
|
127
|
+
// A tmux env whose status line renders @wakeup -> the extension uses the bar.
|
|
128
|
+
const TMUX_ENV = { TMUX: "/tmp/sock,42,0", TMUX_PANE: "%7" };
|
|
129
|
+
|
|
66
130
|
test("registers both tools", () => {
|
|
67
131
|
const h = harness();
|
|
68
132
|
assert.ok(h.tools.has("schedule_wakeup"));
|
|
@@ -177,3 +241,93 @@ test("session_shutdown clears every timer and no wakeup can still deliver", asyn
|
|
|
177
241
|
h.timers.forEach((t) => t.fn());
|
|
178
242
|
assert.equal(h.sent.length, 0);
|
|
179
243
|
});
|
|
244
|
+
|
|
245
|
+
// --- Countdown display -----------------------------------------------------
|
|
246
|
+
|
|
247
|
+
test("widget mode: scheduling shows a line above the editor and arms a refresh", async () => {
|
|
248
|
+
const h = harness(); // no tmux env -> widget
|
|
249
|
+
await h.schedule({ delay_seconds: 120, prompt: "x", reason: "CI" });
|
|
250
|
+
const last = h.widgetCalls.at(-1)!;
|
|
251
|
+
assert.equal(last.key, "wakeup");
|
|
252
|
+
assert.equal(last.lines!.length, 1);
|
|
253
|
+
assert.match(last.lines![0], /2m/); // 120s from now()=1000 -> 2m remaining
|
|
254
|
+
assert.match(last.lines![0], /CI/);
|
|
255
|
+
assert.equal(h.intervals.length, 1);
|
|
256
|
+
assert.equal(h.intervals[0].cancelled, false);
|
|
257
|
+
// The tmux runner is never touched in widget mode.
|
|
258
|
+
assert.equal(h.tmuxSets().length, 0);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("widget mode: a refresh tick re-renders while pending", async () => {
|
|
262
|
+
const h = harness();
|
|
263
|
+
await h.schedule({ delay_seconds: 120, prompt: "x" });
|
|
264
|
+
const before = h.widgetCalls.length;
|
|
265
|
+
h.intervals[0].fn(); // simulate a 30s tick
|
|
266
|
+
assert.ok(h.widgetCalls.length > before);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("widget mode: cancelling the last wakeup clears the widget and stops the refresh", async () => {
|
|
270
|
+
const h = harness();
|
|
271
|
+
const res = await h.schedule({ delay_seconds: 120, prompt: "x" });
|
|
272
|
+
await h.cancel({ id: res.details.id });
|
|
273
|
+
const last = h.widgetCalls.at(-1)!;
|
|
274
|
+
assert.equal(last.lines, undefined); // cleared
|
|
275
|
+
assert.equal(h.intervals[0].cancelled, true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("widget mode: firing clears the display when nothing else is pending", async () => {
|
|
279
|
+
const h = harness({ idle: true });
|
|
280
|
+
await h.schedule({ delay_seconds: 120, prompt: "x" });
|
|
281
|
+
h.fire(h.timers[0]);
|
|
282
|
+
assert.equal(h.widgetCalls.at(-1)!.lines, undefined);
|
|
283
|
+
assert.equal(h.intervals[0].cancelled, true);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("tmux mode: sets @wakeup on the pane session and never draws a widget", async () => {
|
|
287
|
+
const h = harness({ env: TMUX_ENV, statusRight: "#{?@wakeup,x,}" });
|
|
288
|
+
await h.schedule({ delay_seconds: 120, prompt: "x", reason: "CI" });
|
|
289
|
+
const set = h.tmuxSets().at(-1)!;
|
|
290
|
+
assert.deepEqual(set.args.slice(0, 4), ["set-option", "-t", "%7", "@wakeup"]);
|
|
291
|
+
assert.match(set.args[4], /2m/);
|
|
292
|
+
assert.equal(set.socket, "sock"); // basename of /tmp/sock
|
|
293
|
+
assert.equal(h.widgetCalls.length, 0); // nothing in the pi pane
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("tmux mode: cancelling the last wakeup unsets @wakeup", async () => {
|
|
297
|
+
const h = harness({ env: TMUX_ENV, statusRight: "#{?@wakeup,x,}" });
|
|
298
|
+
await h.cancel({}); // nothing pending -> no-op, but prove the unset path on real cancel
|
|
299
|
+
const res = await h.schedule({ delay_seconds: 120, prompt: "x" });
|
|
300
|
+
await h.cancel({ id: res.details.id });
|
|
301
|
+
const set = h.tmuxSets().at(-1)!;
|
|
302
|
+
assert.deepEqual(set.args, ["set-option", "-u", "-t", "%7", "@wakeup"]);
|
|
303
|
+
assert.equal(h.widgetCalls.length, 0);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("tmux mode is NOT used when the status line doesn't render @wakeup", async () => {
|
|
307
|
+
const h = harness({ env: TMUX_ENV, statusRight: "plain bar", statusLeft: "also plain" });
|
|
308
|
+
await h.schedule({ delay_seconds: 120, prompt: "x" });
|
|
309
|
+
// Falls back to the widget; no @wakeup option set.
|
|
310
|
+
assert.ok(h.widgetCalls.length > 0);
|
|
311
|
+
assert.equal(h.tmuxSets().length, 0);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("tmux mode: a throwing set-option is contained and does not fail the schedule", async () => {
|
|
315
|
+
// Decision lands on tmux (show calls succeed); the set-option write throws.
|
|
316
|
+
const h = harness({ env: TMUX_ENV, statusRight: "#{?@wakeup,x,}", tmuxThrows: true });
|
|
317
|
+
await assert.doesNotReject(() => h.schedule({ delay_seconds: 120, prompt: "x" }));
|
|
318
|
+
assert.ok(h.tmuxSets().length >= 1); // it tried the write
|
|
319
|
+
assert.equal(h.widgetCalls.length, 0); // still never drew a widget
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("a throwing setWidget is contained and does not fail the schedule", async () => {
|
|
323
|
+
const h = harness({ widgetThrows: true });
|
|
324
|
+
await assert.doesNotReject(() => h.schedule({ delay_seconds: 120, prompt: "x" }));
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("session_shutdown clears the display and stops the refresh interval", async () => {
|
|
328
|
+
const h = harness();
|
|
329
|
+
await h.schedule({ delay_seconds: 120, prompt: "x" });
|
|
330
|
+
h.handlers.get("session_shutdown")!();
|
|
331
|
+
assert.equal(h.widgetCalls.at(-1)!.lines, undefined); // cleared
|
|
332
|
+
assert.equal(h.intervals[0].cancelled, true);
|
|
333
|
+
});
|
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
|
-
|
|
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,104 @@ 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
|
+
// Render the current pending list to the chosen target, or clear it when
|
|
198
|
+
// nothing is pending. Runs inside timer/interval callbacks, so every tmux and
|
|
199
|
+
// ctx.ui call is guarded — a throw here must never escape (best-effort).
|
|
200
|
+
function refresh(): void {
|
|
201
|
+
const view = [...pending.values()].map((p) => ({ fireAtMs: p.fireAtMs, reason: p.reason }));
|
|
202
|
+
const text = formatCountdown(view, now(), formatClock);
|
|
203
|
+
try {
|
|
204
|
+
if (displayTarget === "tmux" && tmuxSocket && tmuxPane) {
|
|
205
|
+
// Follows muster's @muster_inbox pattern: set the user option on THIS
|
|
206
|
+
// pane's session; `-u` unsets it so the status segment collapses.
|
|
207
|
+
if (text === undefined) {
|
|
208
|
+
runTmux(tmuxSocket, ["set-option", "-u", "-t", tmuxPane, "@wakeup"]);
|
|
209
|
+
} else {
|
|
210
|
+
runTmux(tmuxSocket, ["set-option", "-t", tmuxPane, "@wakeup", text]);
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
// Widget mode. Guard ctx.ui: a ctx captured at session_start goes stale
|
|
215
|
+
// after /reload or a session switch, and setWidget would then throw.
|
|
216
|
+
if (ctx?.ui?.setWidget) {
|
|
217
|
+
ctx.ui.setWidget("wakeup", text === undefined ? undefined : [text]);
|
|
218
|
+
}
|
|
219
|
+
} catch (error) {
|
|
220
|
+
log(`display refresh failed: ${String(error)}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Re-render now and keep the refresh interval in step with the pending set:
|
|
225
|
+
// start it when something is pending, stop it (and clear the display implied
|
|
226
|
+
// by an empty set) when nothing is.
|
|
227
|
+
function syncDisplay(): void {
|
|
228
|
+
refresh();
|
|
229
|
+
if (pending.size === 0) {
|
|
230
|
+
stopInterval();
|
|
231
|
+
} else if (intervalHandle === undefined) {
|
|
232
|
+
intervalHandle = setIntervalFn(() => {
|
|
233
|
+
try {
|
|
234
|
+
refresh();
|
|
235
|
+
} catch {
|
|
236
|
+
// refresh is already guarded; this is belt-and-braces for the callback.
|
|
237
|
+
}
|
|
238
|
+
if (pending.size === 0) stopInterval();
|
|
239
|
+
}, REFRESH_MS);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function stopInterval(): void {
|
|
244
|
+
if (intervalHandle !== undefined) {
|
|
245
|
+
clearIntervalFn(intervalHandle);
|
|
246
|
+
intervalHandle = undefined;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
99
250
|
function forget(id: string): void {
|
|
100
251
|
const handle = timers.get(id);
|
|
101
252
|
if (handle !== undefined) clearTimer(handle);
|
|
@@ -107,8 +258,9 @@ export function createWakeup(pi: any, deps: Deps = {}): void {
|
|
|
107
258
|
const w = pending.get(id);
|
|
108
259
|
if (!w) return;
|
|
109
260
|
// Remove BEFORE delivering: one-shot, and a throw in sendMessage must not
|
|
110
|
-
// leave a half-fired wakeup armed.
|
|
261
|
+
// leave a half-fired wakeup armed. Refresh the countdown off the firing.
|
|
111
262
|
forget(id);
|
|
263
|
+
syncDisplay();
|
|
112
264
|
// Reading ctx.isIdle() can THROW: a ctx captured at session_start goes
|
|
113
265
|
// stale after /reload, fork, or a session switch (assertActive() throws),
|
|
114
266
|
// and this runs up to an hour later inside a setTimeout callback where an
|
|
@@ -153,11 +305,19 @@ export function createWakeup(pi: any, deps: Deps = {}): void {
|
|
|
153
305
|
|
|
154
306
|
pi.on("session_start", (_event: unknown, sessionCtx: any) => {
|
|
155
307
|
ctx = sessionCtx;
|
|
308
|
+
// Decide the display target once, now, from the env + tmux status strings.
|
|
309
|
+
decideDisplay();
|
|
310
|
+
// Nothing is pending at a fresh start, so this just clears any stale display.
|
|
311
|
+
refresh();
|
|
156
312
|
});
|
|
157
313
|
pi.on("session_shutdown", () => {
|
|
158
314
|
for (const handle of timers.values()) clearTimer(handle);
|
|
159
315
|
timers.clear();
|
|
160
316
|
pending.clear();
|
|
317
|
+
stopInterval();
|
|
318
|
+
// Clear the display: with pending now empty, refresh() unsets the option /
|
|
319
|
+
// removes the widget.
|
|
320
|
+
refresh();
|
|
161
321
|
});
|
|
162
322
|
|
|
163
323
|
pi.registerTool({
|
|
@@ -181,7 +341,7 @@ export function createWakeup(pi: any, deps: Deps = {}): void {
|
|
|
181
341
|
counter += 1;
|
|
182
342
|
const id = `wake-${counter}`;
|
|
183
343
|
const fireAtMs = now() + delay * 1000;
|
|
184
|
-
pending.set(id, { prompt, reason });
|
|
344
|
+
pending.set(id, { prompt, reason, fireAtMs });
|
|
185
345
|
const handle = setTimer(() => deliver(id), delay * 1000);
|
|
186
346
|
timers.set(id, handle);
|
|
187
347
|
try {
|
|
@@ -189,6 +349,8 @@ export function createWakeup(pi: any, deps: Deps = {}): void {
|
|
|
189
349
|
} catch {
|
|
190
350
|
// best-effort persistence
|
|
191
351
|
}
|
|
352
|
+
// Show the new wakeup and arm the refresh loop.
|
|
353
|
+
syncDisplay();
|
|
192
354
|
const reasonText = reason ? ` (${reason})` : "";
|
|
193
355
|
return {
|
|
194
356
|
content: [{ type: "text", text: `Scheduled ${id}: waking in ${delay}s${reasonText}.` }],
|
|
@@ -214,6 +376,8 @@ export function createWakeup(pi: any, deps: Deps = {}): void {
|
|
|
214
376
|
} catch {
|
|
215
377
|
// best-effort persistence
|
|
216
378
|
}
|
|
379
|
+
// Update (or clear) the countdown now that one is gone.
|
|
380
|
+
syncDisplay();
|
|
217
381
|
}
|
|
218
382
|
return {
|
|
219
383
|
content: [
|
|
@@ -229,6 +393,8 @@ export function createWakeup(pi: any, deps: Deps = {}): void {
|
|
|
229
393
|
} catch {
|
|
230
394
|
// best-effort persistence
|
|
231
395
|
}
|
|
396
|
+
// Everything's gone: clear the display and stop the refresh loop.
|
|
397
|
+
syncDisplay();
|
|
232
398
|
}
|
|
233
399
|
return { content: [{ type: "text", text: `Cancelled ${count} pending wakeup${count === 1 ? "" : "s"}.` }] };
|
|
234
400
|
},
|
package/src/schedule.test.ts
CHANGED
|
@@ -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
|
+
}
|