pi-wakeup 0.1.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 +31 -0
- package/package.json +37 -0
- package/src/index.test.ts +179 -0
- package/src/index.ts +240 -0
- package/src/schedule.test.ts +54 -0
- package/src/schedule.ts +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# pi-wakeup
|
|
2
|
+
|
|
3
|
+
A pi extension giving a running session two tools to pace itself — the pi analog of Claude Code's `ScheduleWakeup` in dynamic mode.
|
|
4
|
+
|
|
5
|
+
## Tools
|
|
6
|
+
|
|
7
|
+
- **`schedule_wakeup(delay_seconds, prompt, reason?)`** — ask to be woken after a delay. When the timer fires, `prompt` is delivered back into the session: immediately as a steered turn if the session is idle, or queued as a `followUp` if a turn is already running. `delay_seconds` is clamped to `[60, 3600]`. Returns a wakeup id.
|
|
8
|
+
- **`cancel_wakeup(id?)`** — cancel one pending wakeup by id, or every pending wakeup when the id is omitted.
|
|
9
|
+
|
|
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
|
+
|
|
12
|
+
## Design
|
|
13
|
+
|
|
14
|
+
- **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.
|
|
15
|
+
- **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.
|
|
16
|
+
- **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.
|
|
17
|
+
- **Persistence.** Each schedule / fire / cancel is written with `pi.appendEntry` so the session transcript records the wakeup lifecycle.
|
|
18
|
+
|
|
19
|
+
## Known follow-up
|
|
20
|
+
|
|
21
|
+
**Re-arming a pending wakeup across a session *resume* is not yet implemented.** The extension API exposes no entry read-back, and reconstructing pending wakeups by parsing the transcript blind is exactly the kind of vacuous code this project avoids. Within a live session, wakeups fire; a session closed with a pending wakeup drops it on resume. When pi exposes a custom-entry reader (or a resume hook carrying prior entries), `SessionStartEvent.reason === "resume"` is the place to re-arm from the persisted `wakeup_scheduled` / `wakeup_fired` / `wakeup_cancelled` records.
|
|
22
|
+
|
|
23
|
+
## Tests
|
|
24
|
+
|
|
25
|
+
`node --test src/*.test.ts` — pure clamp/delivery/message logic plus the wired tool behavior (registration, idle-vs-busy delivery, clamp, cancel-one, cancel-all, a throwing `sendMessage` contained, shutdown cleanup) against a fake pi with an injectable timer.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
pi install npm:pi-wakeup
|
|
31
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-wakeup",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/schuettc/pi-extensions.git",
|
|
10
|
+
"directory": "packages/pi-wakeup"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"pi-package",
|
|
14
|
+
"pi-extension",
|
|
15
|
+
"pi-coding-agent",
|
|
16
|
+
"wakeup",
|
|
17
|
+
"scheduling",
|
|
18
|
+
"self-pacing"
|
|
19
|
+
],
|
|
20
|
+
"files": [
|
|
21
|
+
"src",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"pi": {
|
|
25
|
+
"extensions": [
|
|
26
|
+
"./src/index.ts"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
31
|
+
"test": "node --test src/*.test.ts"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@earendil-works/pi-coding-agent": "^0.84.3",
|
|
35
|
+
"typescript": "^5.6.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createWakeup } from "./index.ts";
|
|
4
|
+
import type { Deps } from "./index.ts";
|
|
5
|
+
|
|
6
|
+
type Timer = { fn: () => void; ms: number; cancelled: boolean };
|
|
7
|
+
|
|
8
|
+
function harness(opts: { idle?: boolean; sendThrows?: boolean; idleThrows?: boolean } = {}) {
|
|
9
|
+
const idle = opts.idle ?? true;
|
|
10
|
+
const tools = new Map<string, any>();
|
|
11
|
+
const handlers = new Map<string, (event?: unknown, ctx?: unknown) => void>();
|
|
12
|
+
const sent: Array<{ msg: any; opts: any }> = [];
|
|
13
|
+
const entries: Array<{ type: string; data: any }> = [];
|
|
14
|
+
const notifies: Array<{ text: string; level: string }> = [];
|
|
15
|
+
const timers: Timer[] = [];
|
|
16
|
+
|
|
17
|
+
const ctx = {
|
|
18
|
+
isIdle: () => {
|
|
19
|
+
if (opts.idleThrows) throw new Error("stale ctx");
|
|
20
|
+
return idle;
|
|
21
|
+
},
|
|
22
|
+
ui: { notify: (text: string, level: string) => notifies.push({ text, level }) },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const pi = {
|
|
26
|
+
registerTool(t: any) {
|
|
27
|
+
tools.set(t.name, t);
|
|
28
|
+
},
|
|
29
|
+
on(ev: string, h: (event?: unknown, c?: unknown) => void) {
|
|
30
|
+
handlers.set(ev, h);
|
|
31
|
+
},
|
|
32
|
+
sendMessage(msg: any, o: any) {
|
|
33
|
+
if (opts.sendThrows) throw new Error("boom");
|
|
34
|
+
sent.push({ msg, opts: o });
|
|
35
|
+
},
|
|
36
|
+
appendEntry(type: string, data: any) {
|
|
37
|
+
entries.push({ type, data });
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const deps: Deps = {
|
|
42
|
+
setTimer: (fn: () => void, ms: number) => {
|
|
43
|
+
const t: Timer = { fn, ms, cancelled: false };
|
|
44
|
+
timers.push(t);
|
|
45
|
+
return t;
|
|
46
|
+
},
|
|
47
|
+
clearTimer: (handle: unknown) => {
|
|
48
|
+
(handle as Timer).cancelled = true;
|
|
49
|
+
},
|
|
50
|
+
now: () => 1000,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
createWakeup(pi, deps);
|
|
54
|
+
// Prime the captured ctx as a real session would via session_start.
|
|
55
|
+
handlers.get("session_start")?.(undefined, ctx);
|
|
56
|
+
|
|
57
|
+
const schedule = (params: any) =>
|
|
58
|
+
tools.get("schedule_wakeup").execute("call", params, undefined, undefined, ctx);
|
|
59
|
+
const cancel = (params: any) => tools.get("cancel_wakeup").execute("call", params);
|
|
60
|
+
const fire = (t: Timer) => {
|
|
61
|
+
if (!t.cancelled) t.fn();
|
|
62
|
+
};
|
|
63
|
+
return { tools, handlers, sent, entries, notifies, timers, schedule, cancel, fire };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test("registers both tools", () => {
|
|
67
|
+
const h = harness();
|
|
68
|
+
assert.ok(h.tools.has("schedule_wakeup"));
|
|
69
|
+
assert.ok(h.tools.has("cancel_wakeup"));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("scheduling arms a timer, persists, and firing while idle steers a fresh turn", async () => {
|
|
73
|
+
const h = harness({ idle: true });
|
|
74
|
+
const res = await h.schedule({ delay_seconds: 120, prompt: "check the build", reason: "CI" });
|
|
75
|
+
assert.match(res.content[0].text, /waking in 120s/);
|
|
76
|
+
assert.equal(h.timers.length, 1);
|
|
77
|
+
assert.equal(h.timers[0].ms, 120000);
|
|
78
|
+
assert.ok(h.entries.some((e) => e.type === "wakeup_scheduled" && e.data.delaySeconds === 120));
|
|
79
|
+
|
|
80
|
+
h.fire(h.timers[0]);
|
|
81
|
+
assert.equal(h.sent.length, 1);
|
|
82
|
+
assert.equal(h.sent[0].opts.triggerTurn, true); // ALWAYS true — see the dispatch note
|
|
83
|
+
assert.equal(h.sent[0].opts.deliverAs, "steer");
|
|
84
|
+
assert.match(h.sent[0].msg.content, /check the build/);
|
|
85
|
+
assert.match(h.sent[0].msg.content, /CI/);
|
|
86
|
+
assert.equal(h.sent[0].msg.customType, "wakeup");
|
|
87
|
+
assert.ok(h.entries.some((e) => e.type === "wakeup_fired"));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("firing while a turn is streaming keeps triggerTurn:true and queues as followUp", async () => {
|
|
91
|
+
const h = harness({ idle: false });
|
|
92
|
+
await h.schedule({ delay_seconds: 300, prompt: "ping" });
|
|
93
|
+
h.fire(h.timers[0]);
|
|
94
|
+
assert.equal(h.sent[0].opts.triggerTurn, true); // NOT false — false lands in pi's dead branch
|
|
95
|
+
assert.equal(h.sent[0].opts.deliverAs, "followUp");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("delay is clamped up to the floor", async () => {
|
|
99
|
+
const h = harness();
|
|
100
|
+
const res = await h.schedule({ delay_seconds: 1, prompt: "x" });
|
|
101
|
+
assert.match(res.content[0].text, /waking in 60s/);
|
|
102
|
+
assert.equal(h.timers[0].ms, 60000);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("delay is clamped down to the ceiling", async () => {
|
|
106
|
+
const h = harness();
|
|
107
|
+
const res = await h.schedule({ delay_seconds: 999999, prompt: "x" });
|
|
108
|
+
assert.match(res.content[0].text, /waking in 3600s/);
|
|
109
|
+
assert.equal(h.timers[0].ms, 3600000);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("cancel_wakeup by id prevents delivery and records the cancel", async () => {
|
|
113
|
+
const h = harness();
|
|
114
|
+
const res = await h.schedule({ delay_seconds: 120, prompt: "x" });
|
|
115
|
+
const id = res.details.id;
|
|
116
|
+
const c = await h.cancel({ id });
|
|
117
|
+
assert.match(c.content[0].text, new RegExp(`Cancelled ${id}`));
|
|
118
|
+
assert.equal(h.timers[0].cancelled, true);
|
|
119
|
+
h.fire(h.timers[0]);
|
|
120
|
+
assert.equal(h.sent.length, 0);
|
|
121
|
+
assert.ok(h.entries.some((e) => e.type === "wakeup_cancelled" && e.data.id === id));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("cancel_wakeup of an unknown id records NO cancel entry", async () => {
|
|
125
|
+
const h = harness();
|
|
126
|
+
const c = await h.cancel({ id: "wake-999" });
|
|
127
|
+
assert.match(c.content[0].text, /No pending wakeup wake-999/);
|
|
128
|
+
assert.equal(h.entries.filter((e) => e.type === "wakeup_cancelled").length, 0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("cancel_wakeup with no id cancels all pending", async () => {
|
|
132
|
+
const h = harness();
|
|
133
|
+
await h.schedule({ delay_seconds: 120, prompt: "a" });
|
|
134
|
+
await h.schedule({ delay_seconds: 200, prompt: "b" });
|
|
135
|
+
const res = await h.cancel({});
|
|
136
|
+
assert.match(res.content[0].text, /Cancelled 2 pending wakeups/);
|
|
137
|
+
h.timers.forEach(h.fire);
|
|
138
|
+
assert.equal(h.sent.length, 0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("cancel-all with nothing pending reports zero and records no entry", async () => {
|
|
142
|
+
const h = harness();
|
|
143
|
+
const res = await h.cancel({});
|
|
144
|
+
assert.match(res.content[0].text, /Cancelled 0 pending wakeups/);
|
|
145
|
+
assert.equal(h.entries.filter((e) => e.type === "wakeup_cancelled").length, 0);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("a throwing sendMessage is contained, recorded as a failure, and the wakeup is not left armed", async () => {
|
|
149
|
+
const h = harness({ sendThrows: true });
|
|
150
|
+
await h.schedule({ delay_seconds: 120, prompt: "x" });
|
|
151
|
+
assert.doesNotThrow(() => h.fire(h.timers[0]));
|
|
152
|
+
// Observable: a delivery-failed entry (NOT a silent vanish), and no fired entry.
|
|
153
|
+
assert.equal(h.entries.filter((e) => e.type === "wakeup_delivery_failed").length, 1);
|
|
154
|
+
assert.equal(h.entries.filter((e) => e.type === "wakeup_fired").length, 0);
|
|
155
|
+
assert.equal(h.notifies.length, 1);
|
|
156
|
+
// Removed on the first fire: firing the same handle again does nothing new.
|
|
157
|
+
h.fire(h.timers[0]);
|
|
158
|
+
assert.equal(h.entries.filter((e) => e.type === "wakeup_delivery_failed").length, 1);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("a stale ctx whose isIdle() throws (after /reload) does not crash the timer callback; delivers as followUp", async () => {
|
|
162
|
+
const h = harness({ idleThrows: true });
|
|
163
|
+
await h.schedule({ delay_seconds: 120, prompt: "x" });
|
|
164
|
+
assert.doesNotThrow(() => h.fire(h.timers[0]));
|
|
165
|
+
assert.equal(h.sent.length, 1);
|
|
166
|
+
assert.equal(h.sent[0].opts.triggerTurn, true);
|
|
167
|
+
assert.equal(h.sent[0].opts.deliverAs, "followUp"); // safe fallback: never "steer"
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("session_shutdown clears every timer and no wakeup can still deliver", async () => {
|
|
171
|
+
const h = harness();
|
|
172
|
+
await h.schedule({ delay_seconds: 120, prompt: "a" });
|
|
173
|
+
await h.schedule({ delay_seconds: 200, prompt: "b" });
|
|
174
|
+
h.handlers.get("session_shutdown")!();
|
|
175
|
+
assert.ok(h.timers.every((t) => t.cancelled));
|
|
176
|
+
// Even if a stray timer callback still ran, pending was cleared, so nothing delivers.
|
|
177
|
+
h.timers.forEach((t) => t.fn());
|
|
178
|
+
assert.equal(h.sent.length, 0);
|
|
179
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// pi-wakeup — schedule_wakeup / cancel_wakeup tools.
|
|
2
|
+
//
|
|
3
|
+
// The pi analog of Claude Code's ScheduleWakeup in dynamic mode: a running
|
|
4
|
+
// session can pace itself, asking to be woken after a delay to re-check
|
|
5
|
+
// something (a build, a remote queue) without a human poking it. Wakeups are
|
|
6
|
+
// session-local — an in-process timer, not a cron job. The pure decisions
|
|
7
|
+
// (clamp, delivery mode, message) live in schedule.ts; this file is the thin,
|
|
8
|
+
// best-effort pi wiring, in the same split guardrails uses.
|
|
9
|
+
//
|
|
10
|
+
// Delivery: at fire time we ask pi's authoritative ctx.isIdle() rather than
|
|
11
|
+
// tracking a local busy flag (which races the real streaming state), and we
|
|
12
|
+
// always pass triggerTurn:true — pi's dispatch routes a triggerTurn:false
|
|
13
|
+
// message into a dead branch that neither steers nor queues, so deliverAs is
|
|
14
|
+
// only honoured alongside triggerTurn:true. Idle -> steer a fresh turn; busy
|
|
15
|
+
// -> followUp after the current turn (never interrupts it).
|
|
16
|
+
//
|
|
17
|
+
// Persistence: each schedule / fire / cancel / delivery-failure is written with
|
|
18
|
+
// pi.appendEntry so the session transcript records the wakeup lifecycle.
|
|
19
|
+
// Re-arming a pending wakeup across a session RESUME is a documented follow-up
|
|
20
|
+
// (the extension API exposes no entry read-back). Within a live session,
|
|
21
|
+
// wakeups fire.
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
clampDelaySeconds,
|
|
25
|
+
deliveryMode,
|
|
26
|
+
wakeupMessage,
|
|
27
|
+
} from "./schedule.ts";
|
|
28
|
+
|
|
29
|
+
type Pending = { prompt: string; reason: string };
|
|
30
|
+
|
|
31
|
+
export type Deps = {
|
|
32
|
+
// Injectable so the wiring is testable without real time passing. Default to
|
|
33
|
+
// the globals; a test passes controllable stand-ins.
|
|
34
|
+
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
35
|
+
clearTimer?: (handle: unknown) => void;
|
|
36
|
+
now?: () => number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const SCHEDULE_PARAMS = {
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: {
|
|
42
|
+
delay_seconds: {
|
|
43
|
+
type: "number",
|
|
44
|
+
description: "Seconds from now to wake up. Clamped to [60, 3600].",
|
|
45
|
+
},
|
|
46
|
+
prompt: {
|
|
47
|
+
type: "string",
|
|
48
|
+
description: "The message delivered back into the session when the wakeup fires.",
|
|
49
|
+
},
|
|
50
|
+
reason: {
|
|
51
|
+
type: "string",
|
|
52
|
+
description: "One short line on why you are waking up. Shown with the delivered prompt.",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
required: ["delay_seconds", "prompt"],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const CANCEL_PARAMS = {
|
|
59
|
+
type: "object",
|
|
60
|
+
properties: {
|
|
61
|
+
id: {
|
|
62
|
+
type: "string",
|
|
63
|
+
description: "The wakeup id to cancel. Omit to cancel every pending wakeup.",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export function createWakeup(pi: any, deps: Deps = {}): void {
|
|
69
|
+
const setTimer =
|
|
70
|
+
deps.setTimer ??
|
|
71
|
+
((fn: () => void, ms: number) => {
|
|
72
|
+
const handle = setTimeout(fn, ms);
|
|
73
|
+
// Don't let a pending wakeup hold the event loop open on an exit path
|
|
74
|
+
// that skips session_shutdown. Interactive sessions keep other handles
|
|
75
|
+
// alive, so the timer still fires.
|
|
76
|
+
const unrefable = handle as { unref?: () => void };
|
|
77
|
+
if (typeof unrefable.unref === "function") unrefable.unref();
|
|
78
|
+
return handle;
|
|
79
|
+
});
|
|
80
|
+
const clearTimer =
|
|
81
|
+
deps.clearTimer ?? ((handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>));
|
|
82
|
+
const now = deps.now ?? (() => Date.now());
|
|
83
|
+
|
|
84
|
+
const pending = new Map<string, Pending>();
|
|
85
|
+
const timers = new Map<string, unknown>();
|
|
86
|
+
// The session context, captured from session_start and refreshed on each
|
|
87
|
+
// tool call; deliver() reads ctx.isIdle() at fire time.
|
|
88
|
+
let ctx: any;
|
|
89
|
+
let counter = 0;
|
|
90
|
+
|
|
91
|
+
function log(message: string): void {
|
|
92
|
+
try {
|
|
93
|
+
process.stderr.write(`[wakeup] ${message}\n`);
|
|
94
|
+
} catch {
|
|
95
|
+
// best-effort logging
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function forget(id: string): void {
|
|
100
|
+
const handle = timers.get(id);
|
|
101
|
+
if (handle !== undefined) clearTimer(handle);
|
|
102
|
+
timers.delete(id);
|
|
103
|
+
pending.delete(id);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function deliver(id: string): void {
|
|
107
|
+
const w = pending.get(id);
|
|
108
|
+
if (!w) return;
|
|
109
|
+
// Remove BEFORE delivering: one-shot, and a throw in sendMessage must not
|
|
110
|
+
// leave a half-fired wakeup armed.
|
|
111
|
+
forget(id);
|
|
112
|
+
// Reading ctx.isIdle() can THROW: a ctx captured at session_start goes
|
|
113
|
+
// stale after /reload, fork, or a session switch (assertActive() throws),
|
|
114
|
+
// and this runs up to an hour later inside a setTimeout callback where an
|
|
115
|
+
// escaped throw would kill the pi process. Guard it, and default idle=false
|
|
116
|
+
// on any failure — with triggerTurn:true, "followUp" is safe whether or not
|
|
117
|
+
// a turn is streaming (it queues when busy and still starts a turn when
|
|
118
|
+
// idle), whereas "steer" would interrupt a live turn.
|
|
119
|
+
let idle = false;
|
|
120
|
+
try {
|
|
121
|
+
idle = ctx && typeof ctx.isIdle === "function" ? Boolean(ctx.isIdle()) : false;
|
|
122
|
+
} catch {
|
|
123
|
+
idle = false;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
pi.sendMessage(
|
|
127
|
+
{ content: wakeupMessage(w.prompt, w.reason), customType: "wakeup", display: true },
|
|
128
|
+
{ triggerTurn: true, deliverAs: deliveryMode(idle) },
|
|
129
|
+
);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
// A throw here runs inside the timer callback; uncaught it would kill the
|
|
132
|
+
// pi process. Contain it, and make the loss OBSERVABLE — a silently
|
|
133
|
+
// vanished wakeup is the worst outcome for a self-pacing tool.
|
|
134
|
+
log(`delivery of ${id} failed: ${String(error)}`);
|
|
135
|
+
try {
|
|
136
|
+
pi.appendEntry?.("wakeup_delivery_failed", { id, error: String(error) });
|
|
137
|
+
} catch {
|
|
138
|
+
// best-effort persistence
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
ctx?.ui?.notify?.(`wakeup ${id} could not be delivered: ${String(error)}`, "error");
|
|
142
|
+
} catch {
|
|
143
|
+
// best-effort surface
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
pi.appendEntry?.("wakeup_fired", { id, reason: w.reason });
|
|
149
|
+
} catch {
|
|
150
|
+
// best-effort persistence
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
pi.on("session_start", (_event: unknown, sessionCtx: any) => {
|
|
155
|
+
ctx = sessionCtx;
|
|
156
|
+
});
|
|
157
|
+
pi.on("session_shutdown", () => {
|
|
158
|
+
for (const handle of timers.values()) clearTimer(handle);
|
|
159
|
+
timers.clear();
|
|
160
|
+
pending.clear();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
pi.registerTool({
|
|
164
|
+
name: "schedule_wakeup",
|
|
165
|
+
label: "Schedule Wakeup",
|
|
166
|
+
description:
|
|
167
|
+
"Ask to be woken after a delay to re-check something without a human prompting you. The prompt is delivered back into this session when the timer fires (steered immediately if idle, queued after the current turn if busy). Session-local; delay clamped to [60, 3600] seconds.",
|
|
168
|
+
promptSnippet: "schedule_wakeup(delay_seconds, prompt, reason?) — self-pace: wake this session after a delay",
|
|
169
|
+
parameters: SCHEDULE_PARAMS,
|
|
170
|
+
async execute(
|
|
171
|
+
_id: string,
|
|
172
|
+
params: { delay_seconds: number; prompt: string; reason?: string },
|
|
173
|
+
_signal: unknown,
|
|
174
|
+
_onUpdate: unknown,
|
|
175
|
+
execCtx: any,
|
|
176
|
+
) {
|
|
177
|
+
if (execCtx) ctx = execCtx;
|
|
178
|
+
const delay = clampDelaySeconds(params.delay_seconds);
|
|
179
|
+
const prompt = typeof params.prompt === "string" ? params.prompt : "";
|
|
180
|
+
const reason = typeof params.reason === "string" ? params.reason : "";
|
|
181
|
+
counter += 1;
|
|
182
|
+
const id = `wake-${counter}`;
|
|
183
|
+
const fireAtMs = now() + delay * 1000;
|
|
184
|
+
pending.set(id, { prompt, reason });
|
|
185
|
+
const handle = setTimer(() => deliver(id), delay * 1000);
|
|
186
|
+
timers.set(id, handle);
|
|
187
|
+
try {
|
|
188
|
+
pi.appendEntry?.("wakeup_scheduled", { id, delaySeconds: delay, reason, fireAtMs });
|
|
189
|
+
} catch {
|
|
190
|
+
// best-effort persistence
|
|
191
|
+
}
|
|
192
|
+
const reasonText = reason ? ` (${reason})` : "";
|
|
193
|
+
return {
|
|
194
|
+
content: [{ type: "text", text: `Scheduled ${id}: waking in ${delay}s${reasonText}.` }],
|
|
195
|
+
details: { id, delaySeconds: delay },
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
pi.registerTool({
|
|
201
|
+
name: "cancel_wakeup",
|
|
202
|
+
label: "Cancel Wakeup",
|
|
203
|
+
description:
|
|
204
|
+
"Cancel a pending wakeup by id, or omit the id to cancel every pending wakeup in this session.",
|
|
205
|
+
promptSnippet: "cancel_wakeup(id?) — cancel a pending wakeup, or all of them",
|
|
206
|
+
parameters: CANCEL_PARAMS,
|
|
207
|
+
async execute(_id: string, params: { id?: string }) {
|
|
208
|
+
if (params && typeof params.id === "string" && params.id) {
|
|
209
|
+
const existed = pending.has(params.id);
|
|
210
|
+
forget(params.id);
|
|
211
|
+
if (existed) {
|
|
212
|
+
try {
|
|
213
|
+
pi.appendEntry?.("wakeup_cancelled", { id: params.id });
|
|
214
|
+
} catch {
|
|
215
|
+
// best-effort persistence
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
content: [
|
|
220
|
+
{ type: "text", text: existed ? `Cancelled ${params.id}.` : `No pending wakeup ${params.id}.` },
|
|
221
|
+
],
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const count = pending.size;
|
|
225
|
+
for (const id of [...pending.keys()]) forget(id);
|
|
226
|
+
if (count > 0) {
|
|
227
|
+
try {
|
|
228
|
+
pi.appendEntry?.("wakeup_cancelled", { all: true, count });
|
|
229
|
+
} catch {
|
|
230
|
+
// best-effort persistence
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return { content: [{ type: "text", text: `Cancelled ${count} pending wakeup${count === 1 ? "" : "s"}.` }] };
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export default function (pi: any) {
|
|
239
|
+
createWakeup(pi);
|
|
240
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
clampDelaySeconds,
|
|
5
|
+
deliveryMode,
|
|
6
|
+
wakeupMessage,
|
|
7
|
+
MIN_DELAY_SECONDS,
|
|
8
|
+
MAX_DELAY_SECONDS,
|
|
9
|
+
} from "./schedule.ts";
|
|
10
|
+
|
|
11
|
+
test("clampDelaySeconds: below the floor clamps up to the minimum", () => {
|
|
12
|
+
assert.equal(clampDelaySeconds(1), MIN_DELAY_SECONDS);
|
|
13
|
+
assert.equal(clampDelaySeconds(0), MIN_DELAY_SECONDS);
|
|
14
|
+
assert.equal(clampDelaySeconds(-100), MIN_DELAY_SECONDS);
|
|
15
|
+
assert.equal(clampDelaySeconds(59), MIN_DELAY_SECONDS);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("clampDelaySeconds: above the ceiling clamps down to the maximum", () => {
|
|
19
|
+
assert.equal(clampDelaySeconds(3601), MAX_DELAY_SECONDS);
|
|
20
|
+
assert.equal(clampDelaySeconds(999999), MAX_DELAY_SECONDS);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("clampDelaySeconds: an in-range value is floored, not rounded", () => {
|
|
24
|
+
assert.equal(clampDelaySeconds(120), 120);
|
|
25
|
+
assert.equal(clampDelaySeconds(120.9), 120);
|
|
26
|
+
assert.equal(clampDelaySeconds(MIN_DELAY_SECONDS), MIN_DELAY_SECONDS);
|
|
27
|
+
assert.equal(clampDelaySeconds(MAX_DELAY_SECONDS), MAX_DELAY_SECONDS);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("clampDelaySeconds: a non-finite or non-numeric delay falls back to the minimum, never throws", () => {
|
|
31
|
+
assert.equal(clampDelaySeconds(Number.NaN), MIN_DELAY_SECONDS);
|
|
32
|
+
assert.equal(clampDelaySeconds(Number.POSITIVE_INFINITY), MIN_DELAY_SECONDS);
|
|
33
|
+
// @ts-expect-error — deliberately passing a bad type the schema might miss
|
|
34
|
+
assert.equal(clampDelaySeconds("120"), MIN_DELAY_SECONDS);
|
|
35
|
+
// @ts-expect-error
|
|
36
|
+
assert.equal(clampDelaySeconds(undefined), MIN_DELAY_SECONDS);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("deliveryMode: idle steers a fresh turn, busy queues as followUp", () => {
|
|
40
|
+
assert.equal(deliveryMode(true), "steer"); // idle
|
|
41
|
+
assert.equal(deliveryMode(false), "followUp"); // busy/streaming
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("wakeupMessage: a reason is appended in parentheses", () => {
|
|
45
|
+
assert.equal(
|
|
46
|
+
wakeupMessage("check the build", "waiting on CI"),
|
|
47
|
+
"check the build\n\n(scheduled wakeup — waiting on CI)",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("wakeupMessage: an empty or whitespace reason leaves the prompt bare", () => {
|
|
52
|
+
assert.equal(wakeupMessage("check the build", ""), "check the build");
|
|
53
|
+
assert.equal(wakeupMessage("check the build", " "), "check the build");
|
|
54
|
+
});
|
package/src/schedule.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Pure decision logic for the wakeup extension. No pi imports, no timers,
|
|
2
|
+
// no I/O — so every rule here is exercised by node --test with zero mocking,
|
|
3
|
+
// exactly the split the guardrails package uses (gate.ts vs index.ts).
|
|
4
|
+
|
|
5
|
+
// pi-side clamp matching Claude Code's ScheduleWakeup: a wakeup delay under a
|
|
6
|
+
// minute would busy-loop the session, and one over an hour is a scheduler's
|
|
7
|
+
// job, not a session's self-pacing tool.
|
|
8
|
+
export const MIN_DELAY_SECONDS = 60;
|
|
9
|
+
export const MAX_DELAY_SECONDS = 3600;
|
|
10
|
+
|
|
11
|
+
// Clamp an arbitrary caller-supplied delay into [MIN, MAX]. A non-finite or
|
|
12
|
+
// non-numeric value (the model omitted it, or sent a string that slipped the
|
|
13
|
+
// schema) falls back to the minimum rather than throwing — a guardrail tool
|
|
14
|
+
// must never fail the turn over a bad argument.
|
|
15
|
+
export function clampDelaySeconds(seconds: number): number {
|
|
16
|
+
if (typeof seconds !== "number" || !Number.isFinite(seconds)) return MIN_DELAY_SECONDS;
|
|
17
|
+
const floored = Math.floor(seconds);
|
|
18
|
+
if (floored < MIN_DELAY_SECONDS) return MIN_DELAY_SECONDS;
|
|
19
|
+
if (floored > MAX_DELAY_SECONDS) return MAX_DELAY_SECONDS;
|
|
20
|
+
return floored;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// How to deliver a fired wakeup, given the session's authoritative idle state
|
|
24
|
+
// (ctx.isIdle()). When idle we steer a fresh turn; when a turn is already
|
|
25
|
+
// streaming we queue as a followUp so the wakeup lands after it rather than
|
|
26
|
+
// interrupting it. NOTE: the caller always passes triggerTurn:true — pi's
|
|
27
|
+
// dispatch only honours deliverAs while streaming, and routes a
|
|
28
|
+
// triggerTurn:false message into a dead branch that neither steers nor queues.
|
|
29
|
+
export type DeliveryMode = "steer" | "followUp";
|
|
30
|
+
export function deliveryMode(idle: boolean): DeliveryMode {
|
|
31
|
+
return idle ? "steer" : "followUp";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The message content a fired wakeup delivers into the session. The reason is
|
|
35
|
+
// appended in parentheses so the woken turn can see why it was scheduled,
|
|
36
|
+
// mirroring the `reason` field ScheduleWakeup surfaces back to the user.
|
|
37
|
+
export function wakeupMessage(prompt: string, reason: string): string {
|
|
38
|
+
const trimmedReason = typeof reason === "string" ? reason.trim() : "";
|
|
39
|
+
const suffix = trimmedReason ? `\n\n(scheduled wakeup — ${trimmedReason})` : "";
|
|
40
|
+
return `${prompt}${suffix}`;
|
|
41
|
+
}
|