@maxiaochao/pi-toolkit 0.10.6 → 0.10.7

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/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## 2026-10-09 - v0.10.7
2
+
3
+ - bundle scheduler v0.1.0: add session-scoped interval prompts with Agent and user controls
4
+ - Add the bundled and independently installable `@maxiaochao/pi-scheduler` package for session-scoped interval prompts, manual triggering, lightweight TUI status, and shared Agent/user controls.
5
+
1
6
  ## 2026-10-09 - v0.10.6
2
7
 
3
8
  - bundle herdr-subagents v0.4.3: add per-run status, neutral analyst persona, and tool-agnostic Agent Team protocol
package/README.md CHANGED
@@ -34,6 +34,7 @@ Restart Pi after updating. The `todo` tool and widget will no longer load. `pi u
34
34
  | [pi-cache-export](packages/cache-export/README.md) | Tau-aligned interactive cache dashboard for Pi sessions | `pi install npm:@maxiaochao/pi-cache-export` | `pi update npm:@maxiaochao/pi-cache-export` |
35
35
  | [pi-codex-edit](packages/codex-edit/README.md) | Codex-harness `apply_patch` routing for selected GPT models | `pi install npm:@maxiaochao/pi-codex-edit` | `pi update npm:@maxiaochao/pi-codex-edit` |
36
36
  | [pi-herdr-subagents](packages/herdr-subagents/README.md) | Interactive Herdr Pi Agents with bounded concurrency, direct navigation, and recovery | `pi install npm:@maxiaochao/pi-herdr-subagents` | `pi update npm:@maxiaochao/pi-herdr-subagents` |
37
+ | [pi-scheduler](packages/scheduler/README.md) | Session-scoped interval prompts with Agent and `/schedule` controls | `pi install npm:@maxiaochao/pi-scheduler` | `pi update npm:@maxiaochao/pi-scheduler` |
37
38
  | [pi-worktree](packages/worktree/README.md) | Worktree CLI, completion, installer, and agent skill | `pi install npm:@maxiaochao/pi-worktree` | `pi update npm:@maxiaochao/pi-worktree` |
38
39
 
39
40
  Each child README owns its feature description, motivation or upstream alignment, examples, screenshots/demos, configuration, and test documentation.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-toolkit",
3
- "version": "0.10.6",
4
- "description": "Personal pi coding agent toolkit: /btw, /cache_export and apply_patch extensions, browser automation, and bundled theme/skills.",
3
+ "version": "0.10.7",
4
+ "description": "Personal pi coding agent toolkit: scheduler, /btw, /cache_export, apply_patch, browser automation, and bundled theme/skills.",
5
5
  "type": "module",
6
6
  "workspaces": [
7
7
  "packages/*"
@@ -15,6 +15,7 @@
15
15
  "packages/cache-export",
16
16
  "packages/codex-edit",
17
17
  "packages/herdr-subagents",
18
+ "packages/scheduler",
18
19
  "packages/worktree",
19
20
  "themes",
20
21
  "README.md",
@@ -32,7 +33,8 @@
32
33
  "./packages/cache-export/extensions/cache-export.ts",
33
34
  "./extensions/btw/index.ts",
34
35
  "./packages/codex-edit/extensions/codex-edit.ts",
35
- "./packages/herdr-subagents/extensions/herdr-subagents.ts"
36
+ "./packages/herdr-subagents/extensions/herdr-subagents.ts",
37
+ "./packages/scheduler/extensions/scheduler.ts"
36
38
  ],
37
39
  "skills": [
38
40
  "./skills",
@@ -55,8 +57,8 @@
55
57
  "ws": "^8.19.0"
56
58
  },
57
59
  "scripts": {
58
- "test": "node tests/package-layout.mjs && npm --prefix packages/todo test && npm --prefix packages/cache-export test && npm --prefix packages/codex-edit test && npm --prefix packages/herdr-subagents test && npm --prefix packages/worktree test && node --experimental-strip-types extensions/btw/tests/history.mjs",
59
- "load-test": "pi -ne -e ./packages/cache-export/extensions/cache-export.ts && pi -ne -e ./extensions/btw/index.ts && pi -ne -e ./packages/codex-edit/extensions/codex-edit.ts && pi -ne -e ./packages/herdr-subagents/extensions/herdr-subagents.ts",
60
+ "test": "node tests/package-layout.mjs && npm --prefix packages/todo test && npm --prefix packages/cache-export test && npm --prefix packages/codex-edit test && npm --prefix packages/herdr-subagents test && npm --prefix packages/scheduler test && npm --prefix packages/worktree test && node --experimental-strip-types extensions/btw/tests/history.mjs",
61
+ "load-test": "pi -ne -e ./packages/cache-export/extensions/cache-export.ts && pi -ne -e ./extensions/btw/index.ts && pi -ne -e ./packages/codex-edit/extensions/codex-edit.ts && pi -ne -e ./packages/herdr-subagents/extensions/herdr-subagents.ts && pi -ne -e ./packages/scheduler/extensions/scheduler.ts",
60
62
  "eval:agents": "node evaluations/agents-md/run.mjs",
61
63
  "eval:agents:test": "node evaluations/agents-md/tests/run-tests.mjs",
62
64
  "postinstall": "node scripts/install-agents.mjs"
@@ -0,0 +1,5 @@
1
+ ## 2026-10-09 - v0.1.0
2
+
3
+ - add session-scoped interval prompts with Agent and user controls
4
+ - Add session-scoped interval prompts controlled by both the Agent tool and `/schedule` command.
5
+ - Add manual trigger, cancellation, missed-tick coalescing, and lightweight TUI status.
@@ -0,0 +1,57 @@
1
+ # Pi Scheduler
2
+
3
+ Session-scoped interval prompts for Pi. Scheduler does one thing: at a fixed interval, queue a prompt for the current main agent at a safe turn boundary.
4
+
5
+ ![Scheduler status and commands](docs/screenshot.png)
6
+
7
+ ![Scheduler demo](docs/demo.gif)
8
+
9
+ ## Why
10
+
11
+ Use Scheduler when the agent should periodically re-check CI, logs, a review, or another changing condition. It is not Cron, a background process manager, or a durable workflow engine.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pi install npm:@maxiaochao/pi-scheduler
17
+ ```
18
+
19
+ Update it with `pi update npm:@maxiaochao/pi-scheduler`.
20
+
21
+ ## Agent tool
22
+
23
+ ```text
24
+ schedule({ action: "add", every: "5m", prompt: "Check CI; report completion or failure." })
25
+ schedule({ action: "list" })
26
+ schedule({ action: "trigger", id: "schedule-1" })
27
+ schedule({ action: "cancel", id: "schedule-1" })
28
+ ```
29
+
30
+ ## User command
31
+
32
+ ```text
33
+ /schedule add 5m Check CI; report completion or failure.
34
+ /schedule list
35
+ /schedule trigger schedule-1
36
+ /schedule cancel schedule-1
37
+ ```
38
+
39
+ Manual trigger queues one prompt and does not move the regular next time. The TUI status line shows the active count and next trigger.
40
+
41
+ ## Semantics and limits
42
+
43
+ - Intervals accept `s`, `m`, `h`, and `d`, from 1 second through 7 days.
44
+ - Prompts use Pi `followUp`: an active agent turn finishes before the scheduled prompt runs.
45
+ - At most one prompt per schedule remains pending; missed intervals coalesce instead of flooding the queue.
46
+ - Schedules live only in the current session and are cleared on new/resume/fork/reload/quit.
47
+ - Cancel stops future timer events. Pi cannot selectively withdraw a prompt already in its follow-up queue.
48
+ - Timing is best-effort, not real-time. Recursive `setTimeout` uses absolute target times to avoid cumulative drift.
49
+ - Calendar expressions, time zones, persistence, process monitoring, and script notifications are intentionally out of scope.
50
+
51
+ ## Remove
52
+
53
+ ```bash
54
+ pi remove npm:@maxiaochao/pi-scheduler
55
+ ```
56
+
57
+ Do not install both the standalone package and a toolkit release that already bundles Scheduler, or the tool and command will be registered twice.
@@ -0,0 +1,19 @@
1
+ # Testing Pi Scheduler
2
+
3
+ Run from the repository root:
4
+
5
+ ```bash
6
+ npm --prefix packages/scheduler test
7
+ npm --prefix packages/scheduler run load-test
8
+ npm pack ./packages/scheduler --dry-run
9
+ ```
10
+
11
+ The deterministic core test uses a fake clock to verify duration parsing, absolute-time cadence, timed triggering, missed-tick coalescing, manual triggering, dropped-follow-up release, and cancellation. The extension test verifies tool/command parity, manual follow-up delivery, message-specific pending acknowledgement, settled-run fallback cleanup, TUI status, duplicate suppression, and shutdown timer cleanup.
12
+
13
+ For a manual TUI check, run Pi with `-e ./packages/scheduler/extensions/scheduler.ts`, create a short schedule, confirm the status line appears, and verify the prompt starts only after the current turn settles.
14
+
15
+ Regenerate package media with:
16
+
17
+ ```bash
18
+ node scripts/capture-package-media.mjs
19
+ ```
Binary file
@@ -0,0 +1,133 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { Text } from "@earendil-works/pi-tui";
4
+ import { Type } from "typebox";
5
+ import { Scheduler, systemClock, type ScheduleSnapshot } from "../src/scheduler.ts";
6
+
7
+ const Params = Type.Object({
8
+ action: StringEnum(["add", "list", "trigger", "cancel"] as const),
9
+ every: Type.Optional(Type.String({ description: "Interval for add, for example 30s, 5m, or 1h30m" })),
10
+ prompt: Type.Optional(Type.String({ description: "Prompt delivered to the main agent on each trigger" })),
11
+ id: Type.Optional(Type.String({ description: "Schedule ID for trigger or cancel" })),
12
+ });
13
+
14
+ function formatTime(timestamp: number): string {
15
+ return new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
16
+ }
17
+
18
+ function formatList(schedules: ScheduleSnapshot[]): string {
19
+ if (schedules.length === 0) return "No active schedules.";
20
+ return schedules.map((item) =>
21
+ `${item.id} · every ${item.every} · next ${formatTime(item.nextRunAt)}${item.pending ? " · prompt pending" : ""}\n ${item.prompt}`,
22
+ ).join("\n");
23
+ }
24
+
25
+ function parseCommand(input: string): { action: "add" | "list" | "trigger" | "cancel"; every?: string; prompt?: string; id?: string } {
26
+ const text = input.trim();
27
+ if (!text || text === "list") return { action: "list" };
28
+ const [action, first, ...rest] = text.split(/\s+/);
29
+ if (action === "add") return { action, every: first, prompt: rest.join(" ") };
30
+ if (action === "trigger" || action === "cancel") return { action, id: first };
31
+ throw new Error("usage: /schedule add <interval> <prompt> | list | trigger <id> | cancel <id>");
32
+ }
33
+
34
+ export default function (pi: ExtensionAPI) {
35
+ if (process.env.PI_HERDR_SUBAGENTS_CHILD === "1") return;
36
+ let context: ExtensionContext | undefined;
37
+
38
+ const refreshStatus = () => {
39
+ if (!context?.hasUI) return;
40
+ const schedules = scheduler.list();
41
+ const next = schedules[0];
42
+ context.ui.setStatus("scheduler", next ? `⏱ ${schedules.length} schedule${schedules.length === 1 ? "" : "s"} · next ${formatTime(next.nextRunAt)}` : undefined);
43
+ };
44
+
45
+ const scheduler = new Scheduler({
46
+ clock: systemClock,
47
+ onChange: refreshStatus,
48
+ onTrigger(schedule, source) {
49
+ pi.sendMessage({
50
+ customType: "scheduler-trigger",
51
+ content: `[Scheduled prompt: ${schedule.id}; source=${source}]\n${schedule.prompt}`,
52
+ display: true,
53
+ details: { scheduleId: schedule.id, source, every: schedule.every },
54
+ }, { triggerTurn: true, deliverAs: "followUp" });
55
+ },
56
+ });
57
+
58
+ const perform = (params: { action: "add" | "list" | "trigger" | "cancel"; every?: string; prompt?: string; id?: string }) => {
59
+ if (params.action === "add") {
60
+ if (!params.every || !params.prompt) throw new Error("add requires every and prompt");
61
+ const item = scheduler.add(params.every, params.prompt);
62
+ return `Created ${item.id}: every ${item.every}; next ${formatTime(item.nextRunAt)}.`;
63
+ }
64
+ if (params.action === "list") return formatList(scheduler.list());
65
+ if (!params.id) throw new Error(`${params.action} requires id`);
66
+ if (params.action === "cancel") {
67
+ if (!scheduler.cancel(params.id)) throw new Error(`unknown schedule: ${params.id}`);
68
+ return `Cancelled ${params.id}. Already queued prompts cannot be withdrawn.`;
69
+ }
70
+ const result = scheduler.trigger(params.id);
71
+ if (!result.ok) {
72
+ if (result.reason === "already-pending") throw new Error(`${params.id} already has a pending prompt`);
73
+ throw new Error(`unknown schedule: ${params.id}`);
74
+ }
75
+ return `Triggered ${params.id}; its regular next time is unchanged.`;
76
+ };
77
+
78
+ pi.on("session_start", async (_event, ctx) => {
79
+ context = ctx;
80
+ refreshStatus();
81
+ });
82
+ pi.on("message_start", async (event) => {
83
+ if (event.message.role !== "custom" || event.message.customType !== "scheduler-trigger") return;
84
+ const details = event.message.details as { scheduleId?: unknown } | undefined;
85
+ if (typeof details?.scheduleId === "string") scheduler.acknowledge(details.scheduleId);
86
+ });
87
+ pi.on("agent_settled", async () => {
88
+ // A queued follow-up can be cleared by Pi before it produces message_start.
89
+ // Once the run is fully settled, any still-pending scheduler message is gone.
90
+ scheduler.releaseUndelivered();
91
+ });
92
+ pi.on("session_shutdown", async (_event, ctx) => {
93
+ scheduler.clear();
94
+ if (ctx.hasUI) ctx.ui.setStatus("scheduler", undefined);
95
+ context = undefined;
96
+ });
97
+
98
+ pi.registerTool({
99
+ name: "schedule",
100
+ label: "Schedule",
101
+ description: "Manage session-scoped interval prompts for the main agent. Use add when the user asks the agent to check something repeatedly. Use trigger for one immediate check without changing the regular cadence. Schedules end with the current session.",
102
+ parameters: Params,
103
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
104
+ async execute(_toolCallId, params) {
105
+ try {
106
+ const text = perform(params);
107
+ return { content: [{ type: "text" as const, text }], details: { ...params, schedules: scheduler.list() } };
108
+ } catch (error) {
109
+ const text = error instanceof Error ? error.message : String(error);
110
+ return { content: [{ type: "text" as const, text: `Error: ${text}` }], details: { ...params, error: text }, isError: true };
111
+ }
112
+ },
113
+ renderCall(args, theme) {
114
+ return new Text(`${theme.fg("toolTitle", theme.bold("schedule "))}${theme.fg("muted", args.action)}`, 0, 0);
115
+ },
116
+ renderResult(result, _options, theme) {
117
+ const item = result.content[0];
118
+ return new Text(theme.fg(result.isError ? "error" : "muted", item?.type === "text" ? item.text : ""), 0, 0);
119
+ },
120
+ });
121
+
122
+ pi.registerCommand("schedule", {
123
+ description: "Add, list, trigger, or cancel session-scoped interval prompts",
124
+ handler: async (args, ctx) => {
125
+ try {
126
+ const text = perform(parseCommand(args));
127
+ ctx.ui.notify(text, "info");
128
+ } catch (error) {
129
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
130
+ }
131
+ },
132
+ });
133
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@maxiaochao/pi-scheduler",
3
+ "version": "0.1.0",
4
+ "description": "Session-scoped interval prompts for Pi agents.",
5
+ "type": "module",
6
+ "keywords": ["pi-package", "pi-extension", "scheduler"],
7
+ "files": ["extensions", "src", "docs", "README.md", "TESTING.md", "CHANGELOG.md"],
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/chaochaoxiaochao/pi-toolkit.git",
11
+ "directory": "packages/scheduler"
12
+ },
13
+ "pi": { "extensions": ["./extensions/scheduler.ts"] },
14
+ "peerDependencies": {
15
+ "@earendil-works/pi-ai": "*",
16
+ "@earendil-works/pi-coding-agent": "*",
17
+ "@earendil-works/pi-tui": "*",
18
+ "typebox": "*"
19
+ },
20
+ "scripts": {
21
+ "test": "node --experimental-strip-types tests/scheduler-core.mjs && node --experimental-strip-types tests/scheduler-extension.mjs",
22
+ "load-test": "pi -ne -e ./extensions/scheduler.ts"
23
+ },
24
+ "publishConfig": { "access": "public" }
25
+ }
@@ -0,0 +1,171 @@
1
+ export interface Clock {
2
+ now(): number;
3
+ setTimeout(callback: () => void, delayMs: number): unknown;
4
+ clearTimeout(handle: unknown): void;
5
+ }
6
+
7
+ export interface ScheduleSnapshot {
8
+ id: string;
9
+ every: string;
10
+ intervalMs: number;
11
+ prompt: string;
12
+ nextRunAt: number;
13
+ pending: boolean;
14
+ }
15
+
16
+ interface Schedule extends ScheduleSnapshot {
17
+ timer?: unknown;
18
+ }
19
+
20
+ export interface TriggerResult {
21
+ ok: boolean;
22
+ reason?: "not-found" | "already-pending";
23
+ schedule?: ScheduleSnapshot;
24
+ }
25
+
26
+ const DURATION_PART = /(\d+)(ms|s|m|h|d)/gy;
27
+ const MAX_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
28
+
29
+ export function parseDuration(input: string): number {
30
+ const value = input.trim().toLowerCase();
31
+ if (!value) throw new Error("interval is required");
32
+ let total = 0;
33
+ let offset = 0;
34
+ for (const match of value.matchAll(DURATION_PART)) {
35
+ if (match.index !== offset) throw new Error(`invalid interval: ${input}`);
36
+ const amount = Number(match[1]);
37
+ const unit = match[2];
38
+ const multiplier = unit === "ms" ? 1 : unit === "s" ? 1_000 : unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : 86_400_000;
39
+ total += amount * multiplier;
40
+ offset = match.index + match[0].length;
41
+ }
42
+ if (offset !== value.length || total < 1_000 || total > MAX_INTERVAL_MS) {
43
+ throw new Error("interval must be between 1s and 7d (examples: 30s, 5m, 1h30m)");
44
+ }
45
+ return total;
46
+ }
47
+
48
+ export class Scheduler {
49
+ private readonly clock: Clock;
50
+ private readonly onTrigger: (schedule: ScheduleSnapshot, source: "timer" | "manual") => void;
51
+ private readonly onChange: () => void;
52
+ private readonly schedules = new Map<string, Schedule>();
53
+ private nextId = 1;
54
+
55
+ constructor(options: {
56
+ clock: Clock;
57
+ onTrigger: (schedule: ScheduleSnapshot, source: "timer" | "manual") => void;
58
+ onChange?: () => void;
59
+ }) {
60
+ this.clock = options.clock;
61
+ this.onTrigger = options.onTrigger;
62
+ this.onChange = options.onChange ?? (() => {});
63
+ }
64
+
65
+ add(every: string, prompt: string): ScheduleSnapshot {
66
+ const intervalMs = parseDuration(every);
67
+ const normalizedPrompt = prompt.trim();
68
+ if (!normalizedPrompt) throw new Error("prompt is required");
69
+ const schedule: Schedule = {
70
+ id: `schedule-${this.nextId++}`,
71
+ every: every.trim(),
72
+ intervalMs,
73
+ prompt: normalizedPrompt,
74
+ nextRunAt: this.clock.now() + intervalMs,
75
+ pending: false,
76
+ };
77
+ this.schedules.set(schedule.id, schedule);
78
+ this.arm(schedule);
79
+ this.onChange();
80
+ return this.snapshot(schedule);
81
+ }
82
+
83
+ list(): ScheduleSnapshot[] {
84
+ return [...this.schedules.values()]
85
+ .sort((left, right) => left.nextRunAt - right.nextRunAt || left.id.localeCompare(right.id))
86
+ .map((schedule) => this.snapshot(schedule));
87
+ }
88
+
89
+ trigger(id: string): TriggerResult {
90
+ const schedule = this.schedules.get(id);
91
+ if (!schedule) return { ok: false, reason: "not-found" };
92
+ if (schedule.pending) return { ok: false, reason: "already-pending", schedule: this.snapshot(schedule) };
93
+ this.fire(schedule, "manual");
94
+ return { ok: true, schedule: this.snapshot(schedule) };
95
+ }
96
+
97
+ cancel(id: string): boolean {
98
+ const schedule = this.schedules.get(id);
99
+ if (!schedule) return false;
100
+ if (schedule.timer !== undefined) this.clock.clearTimeout(schedule.timer);
101
+ this.schedules.delete(id);
102
+ this.onChange();
103
+ return true;
104
+ }
105
+
106
+ acknowledge(id: string): boolean {
107
+ const schedule = this.schedules.get(id);
108
+ if (!schedule?.pending) return false;
109
+ schedule.pending = false;
110
+ this.onChange();
111
+ return true;
112
+ }
113
+
114
+ releaseUndelivered(): number {
115
+ let released = 0;
116
+ for (const schedule of this.schedules.values()) {
117
+ if (!schedule.pending) continue;
118
+ schedule.pending = false;
119
+ released += 1;
120
+ }
121
+ if (released > 0) this.onChange();
122
+ return released;
123
+ }
124
+
125
+ clear(): void {
126
+ for (const schedule of this.schedules.values()) {
127
+ if (schedule.timer !== undefined) this.clock.clearTimeout(schedule.timer);
128
+ }
129
+ this.schedules.clear();
130
+ this.onChange();
131
+ }
132
+
133
+ private arm(schedule: Schedule): void {
134
+ const delay = Math.max(0, schedule.nextRunAt - this.clock.now());
135
+ schedule.timer = this.clock.setTimeout(() => this.tick(schedule.id), delay);
136
+ }
137
+
138
+ private tick(id: string): void {
139
+ const schedule = this.schedules.get(id);
140
+ if (!schedule) return;
141
+ const now = this.clock.now();
142
+ do schedule.nextRunAt += schedule.intervalMs;
143
+ while (schedule.nextRunAt <= now);
144
+ if (!schedule.pending) this.fire(schedule, "timer");
145
+ this.arm(schedule);
146
+ this.onChange();
147
+ }
148
+
149
+ private fire(schedule: Schedule, source: "timer" | "manual"): void {
150
+ schedule.pending = true;
151
+ this.onTrigger(this.snapshot(schedule), source);
152
+ this.onChange();
153
+ }
154
+
155
+ private snapshot(schedule: Schedule): ScheduleSnapshot {
156
+ return {
157
+ id: schedule.id,
158
+ every: schedule.every,
159
+ intervalMs: schedule.intervalMs,
160
+ prompt: schedule.prompt,
161
+ nextRunAt: schedule.nextRunAt,
162
+ pending: schedule.pending,
163
+ };
164
+ }
165
+ }
166
+
167
+ export const systemClock: Clock = {
168
+ now: () => Date.now(),
169
+ setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
170
+ clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
171
+ };
@@ -0,0 +1,63 @@
1
+ import assert from "node:assert/strict";
2
+ import { Scheduler, parseDuration } from "../src/scheduler.ts";
3
+
4
+ class FakeClock {
5
+ nowMs = 1_000_000;
6
+ nextHandle = 1;
7
+ timers = new Map();
8
+ now() { return this.nowMs; }
9
+ setTimeout(callback, delayMs) {
10
+ const handle = this.nextHandle++;
11
+ this.timers.set(handle, { at: this.nowMs + delayMs, callback });
12
+ return handle;
13
+ }
14
+ clearTimeout(handle) { this.timers.delete(handle); }
15
+ advance(ms) {
16
+ this.nowMs += ms;
17
+ while (true) {
18
+ const due = [...this.timers.entries()].filter(([, timer]) => timer.at <= this.nowMs).sort((a, b) => a[1].at - b[1].at)[0];
19
+ if (!due) break;
20
+ this.timers.delete(due[0]);
21
+ due[1].callback();
22
+ }
23
+ }
24
+ }
25
+
26
+ assert.equal(parseDuration("5m"), 300_000);
27
+ assert.equal(parseDuration("1h30m"), 5_400_000);
28
+ assert.throws(() => parseDuration("cron * * * * *"), /interval/);
29
+ assert.throws(() => parseDuration("500ms"), /between 1s and 7d/);
30
+
31
+ const clock = new FakeClock();
32
+ const triggers = [];
33
+ let changes = 0;
34
+ const scheduler = new Scheduler({ clock, onTrigger: (schedule, source) => triggers.push({ schedule, source }), onChange: () => { changes += 1; } });
35
+ const added = scheduler.add("5m", "Check CI and report only if finished or failed");
36
+ assert.equal(added.id, "schedule-1");
37
+ assert.equal(added.nextRunAt, 1_300_000);
38
+ assert.equal(scheduler.list().length, 1);
39
+
40
+ clock.advance(300_000);
41
+ assert.equal(triggers.length, 1);
42
+ assert.equal(triggers[0].source, "timer");
43
+ assert.equal(scheduler.list()[0].pending, true);
44
+ assert.equal(scheduler.list()[0].nextRunAt, 1_600_000);
45
+
46
+ clock.advance(900_000);
47
+ assert.equal(triggers.length, 1, "missed intervals coalesce while one prompt is pending");
48
+ assert.equal(scheduler.list()[0].nextRunAt, 2_500_000, "absolute cadence skips missed intervals without drift");
49
+ assert.equal(scheduler.acknowledge(added.id), true);
50
+ assert.equal(scheduler.acknowledge(added.id), false);
51
+ assert.equal(scheduler.trigger(added.id).ok, true);
52
+ assert.equal(triggers.at(-1).source, "manual");
53
+ assert.equal(scheduler.list()[0].nextRunAt, 2_500_000, "manual trigger preserves regular cadence");
54
+ assert.deepEqual(scheduler.trigger(added.id).reason, "already-pending");
55
+ assert.equal(scheduler.releaseUndelivered(), 1);
56
+ assert.equal(scheduler.releaseUndelivered(), 0);
57
+ assert.equal(scheduler.trigger(added.id).ok, true, "a settled run releases a follow-up that never entered message_start");
58
+
59
+ assert.equal(scheduler.cancel(added.id), true);
60
+ assert.equal(scheduler.cancel(added.id), false);
61
+ assert.equal(scheduler.list().length, 0);
62
+ assert.ok(changes >= 5);
63
+ console.log("Scheduler core tests passed");
@@ -0,0 +1,81 @@
1
+ import assert from "node:assert/strict";
2
+ import registerScheduler from "../extensions/scheduler.ts";
3
+
4
+ const handlers = new Map();
5
+ const commands = new Map();
6
+ const messages = [];
7
+ const statuses = new Map();
8
+ let tool;
9
+ const originalSetTimeout = globalThis.setTimeout;
10
+ const originalClearTimeout = globalThis.clearTimeout;
11
+ let nextTimer = 1;
12
+ const timers = new Map();
13
+ globalThis.setTimeout = (callback, delayMs) => {
14
+ const id = nextTimer++;
15
+ timers.set(id, { callback, delayMs });
16
+ return id;
17
+ };
18
+ globalThis.clearTimeout = (id) => timers.delete(id);
19
+ const pi = {
20
+ on(name, handler) { handlers.set(name, handler); },
21
+ registerTool(definition) { tool = definition; },
22
+ registerCommand(name, definition) { commands.set(name, definition); },
23
+ sendMessage(message, options) { messages.push({ message, options }); },
24
+ };
25
+ const childGuard = process.env.PI_HERDR_SUBAGENTS_CHILD;
26
+ delete process.env.PI_HERDR_SUBAGENTS_CHILD;
27
+ try {
28
+ registerScheduler(pi);
29
+ } finally {
30
+ if (childGuard === undefined) delete process.env.PI_HERDR_SUBAGENTS_CHILD;
31
+ else process.env.PI_HERDR_SUBAGENTS_CHILD = childGuard;
32
+ }
33
+ const notifications = [];
34
+ const asAppMessage = ({ message }) => ({ ...message, role: "custom" });
35
+ const context = {
36
+ hasUI: true,
37
+ ui: {
38
+ setStatus(key, value) { if (value === undefined) statuses.delete(key); else statuses.set(key, value); },
39
+ notify(message, level) { notifications.push({ message, level }); },
40
+ },
41
+ };
42
+ await handlers.get("session_start")({}, context);
43
+ assert.ok(tool);
44
+ assert.ok(commands.has("schedule"));
45
+
46
+ const add = await tool.execute("add", { action: "add", every: "1h", prompt: "Check CI" });
47
+ assert.match(add.content[0].text, /Created schedule-1/);
48
+ assert.match(statuses.get("scheduler"), /1 schedule/);
49
+
50
+ const trigger = await tool.execute("trigger", { action: "trigger", id: "schedule-1" });
51
+ assert.match(trigger.content[0].text, /Triggered schedule-1/);
52
+ assert.equal(messages.length, 1);
53
+ assert.deepEqual(messages[0].options, { triggerTurn: true, deliverAs: "followUp" });
54
+ assert.match(messages[0].message.content, /\[Scheduled prompt: schedule-1; source=manual\]\nCheck CI/);
55
+ const duplicate = await tool.execute("trigger-again", { action: "trigger", id: "schedule-1" });
56
+ assert.equal(duplicate.isError, true);
57
+
58
+ await handlers.get("message_start")({ message: { role: "custom", customType: "other", details: { scheduleId: "schedule-1" } } });
59
+ assert.equal((await tool.execute("still-pending", { action: "trigger", id: "schedule-1" })).isError, true, "unrelated custom messages do not acknowledge a schedule");
60
+ await handlers.get("message_start")({ message: asAppMessage(messages[0]) });
61
+ await commands.get("schedule").handler("trigger schedule-1", context);
62
+ assert.equal(messages.length, 2);
63
+ assert.match(notifications.at(-1).message, /regular next time is unchanged/);
64
+ await commands.get("schedule").handler("list", context);
65
+ assert.match(notifications.at(-1).message, /schedule-1/);
66
+
67
+ await handlers.get("agent_settled")({ aborted: false });
68
+ await commands.get("schedule").handler("trigger schedule-1", context);
69
+ assert.equal(messages.length, 3, "settlement releases a queued scheduler message that never reached message_start");
70
+ await handlers.get("message_start")({ message: asAppMessage(messages[2]) });
71
+ await commands.get("schedule").handler("cancel schedule-1", context);
72
+ assert.match(notifications.at(-1).message, /Already queued prompts cannot be withdrawn/);
73
+ assert.equal(statuses.has("scheduler"), false);
74
+ await tool.execute("shutdown-add", { action: "add", every: "2h", prompt: "Check deployment" });
75
+ assert.equal(timers.size, 1, "active schedule owns one timer");
76
+ await handlers.get("session_shutdown")({}, context);
77
+ assert.equal(statuses.has("scheduler"), false);
78
+ assert.equal(timers.size, 0, "session shutdown clears active timers");
79
+ globalThis.setTimeout = originalSetTimeout;
80
+ globalThis.clearTimeout = originalClearTimeout;
81
+ console.log("Scheduler extension integration tests passed");