@maxiaochao/pi-toolkit 0.10.6 → 0.10.8

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.
Files changed (31) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -1
  3. package/package.json +8 -5
  4. package/packages/herdr-subagents/CHANGELOG.md +7 -0
  5. package/packages/herdr-subagents/README.md +4 -2
  6. package/packages/herdr-subagents/TESTING.md +6 -1
  7. package/packages/herdr-subagents/extensions/herdr-subagents.ts +83 -19
  8. package/packages/herdr-subagents/src/batch-runner.ts +3 -1
  9. package/packages/herdr-subagents/src/dispatcher.ts +8 -1
  10. package/packages/herdr-subagents/tests/extension-contract.ts +198 -2
  11. package/packages/herdr-subagents/tests/run-tests.mjs +25 -0
  12. package/packages/scheduler/CHANGELOG.md +10 -0
  13. package/packages/scheduler/README.md +59 -0
  14. package/packages/scheduler/TESTING.md +19 -0
  15. package/packages/scheduler/docs/demo.gif +0 -0
  16. package/packages/scheduler/docs/screenshot.png +0 -0
  17. package/packages/scheduler/extensions/scheduler.ts +206 -0
  18. package/packages/scheduler/package.json +25 -0
  19. package/packages/scheduler/src/scheduler.ts +173 -0
  20. package/packages/scheduler/tests/scheduler-core.mjs +70 -0
  21. package/packages/scheduler/tests/scheduler-extension.mjs +117 -0
  22. package/packages/worktree/CHANGELOG.md +7 -0
  23. package/packages/worktree/README.md +18 -3
  24. package/packages/worktree/TESTING.md +5 -2
  25. package/packages/worktree/bin/pi-worktree +118 -23
  26. package/packages/worktree/bin/pi-worktree-completion.bash +2 -2
  27. package/packages/worktree/extensions/worktree.ts +173 -0
  28. package/packages/worktree/package.json +37 -8
  29. package/packages/worktree/skills/pi-worktree/SKILL.md +11 -6
  30. package/packages/worktree/tests/extension.test.mjs +114 -0
  31. package/packages/worktree/tests/run-tests.sh +86 -0
@@ -0,0 +1,59 @@
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
+ schedule({ action: "clear" })
29
+ ```
30
+
31
+ ## User command
32
+
33
+ ```text
34
+ /schedule add 5m Check CI; report completion or failure.
35
+ /schedule list
36
+ /schedule trigger schedule-1
37
+ /schedule cancel schedule-1
38
+ /schedule clear
39
+ ```
40
+
41
+ Manual trigger queues one prompt and does not move the regular next time. A multi-line widget above the editor shows one row per active schedule with its ID, interval, next trigger, pending state, and prompt. Herdr Fleet remains below the editor, so the two live displays do not compete for the same area or overwrite each other.
42
+
43
+ ## Semantics and limits
44
+
45
+ - Intervals accept `s`, `m`, `h`, and `d`, from 1 second through 7 days.
46
+ - Prompts use Pi `followUp`: an active agent turn finishes before the scheduled prompt runs.
47
+ - At most one prompt per schedule remains pending; missed intervals coalesce instead of flooding the queue.
48
+ - Schedules live only in the current session and are cleared on new/resume/fork/reload/quit.
49
+ - Cancel stops one schedule; clear stops all active schedules. Pi cannot selectively withdraw a prompt already in its follow-up queue.
50
+ - Timing is best-effort, not real-time. Recursive `setTimeout` uses absolute target times to avoid cumulative drift.
51
+ - Calendar expressions, time zones, persistence, process monitoring, and script notifications are intentionally out of scope.
52
+
53
+ ## Remove
54
+
55
+ ```bash
56
+ pi remove npm:@maxiaochao/pi-scheduler
57
+ ```
58
+
59
+ 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, single cancellation, and clear-all timer cleanup. The extension test verifies tool/command parity, manual follow-up delivery, message-specific pending acknowledgement, settled-run fallback cleanup, the above-editor multi-line widget, coexistence with the below-editor Herdr Fleet, duplicate suppression, clear, 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,206 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
3
+ import { Text, truncateToWidth } 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", "clear"] 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
+ type ScheduleAction = "add" | "list" | "trigger" | "cancel" | "clear";
26
+
27
+ function parseCommand(input: string): { action: ScheduleAction; every?: string; prompt?: string; id?: string } {
28
+ const text = input.trim();
29
+ if (!text || text === "list") return { action: "list" };
30
+ const [action, first, ...rest] = text.split(/\s+/);
31
+ if (action === "add") return { action, every: first, prompt: rest.join(" ") };
32
+ if (action === "trigger" || action === "cancel") return { action, id: first };
33
+ if (action === "clear") return { action };
34
+ throw new Error("usage: /schedule add <interval> <prompt> | list | trigger <id> | cancel <id> | clear");
35
+ }
36
+
37
+ const WIDGET_KEY = "scheduler";
38
+
39
+ export class ScheduleWidget {
40
+ private cachedWidth?: number;
41
+ private cachedLines?: string[];
42
+ private readonly getSchedules: () => ScheduleSnapshot[];
43
+ private readonly theme: Theme;
44
+
45
+ constructor(getSchedules: () => ScheduleSnapshot[], theme: Theme) {
46
+ this.getSchedules = getSchedules;
47
+ this.theme = theme;
48
+ }
49
+
50
+ render(width: number): string[] {
51
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
52
+ const schedules = this.getSchedules();
53
+ this.cachedLines = [
54
+ truncateToWidth(this.theme.fg("accent", `⏱ ${schedules.length} active schedule${schedules.length === 1 ? "" : "s"}`), width),
55
+ ...schedules.map((item) => {
56
+ const marker = item.pending ? this.theme.fg("warning", "●") : this.theme.fg("dim", "○");
57
+ const state = item.pending ? " · pending" : "";
58
+ return truncateToWidth(` ${marker} ${this.theme.fg("accent", item.id)} · ${item.every} · next ${formatTime(item.nextRunAt)}${state} · ${this.theme.fg("text", item.prompt)}`, width);
59
+ }),
60
+ ];
61
+ this.cachedWidth = width;
62
+ return this.cachedLines;
63
+ }
64
+
65
+ invalidate(): void {
66
+ this.cachedWidth = undefined;
67
+ this.cachedLines = undefined;
68
+ }
69
+ }
70
+
71
+ interface MountedWidget {
72
+ component: ScheduleWidget;
73
+ tui: { requestRender(): void };
74
+ }
75
+
76
+ export default function (pi: ExtensionAPI) {
77
+ if (process.env.PI_HERDR_SUBAGENTS_CHILD === "1") return;
78
+ let context: ExtensionContext | undefined;
79
+ let mountedWidget: MountedWidget | undefined;
80
+
81
+ const refreshUI = () => {
82
+ if (!context?.hasUI) return;
83
+ const schedules = scheduler.list();
84
+ if (context.mode !== "tui") {
85
+ const next = schedules[0];
86
+ context.ui.setStatus(WIDGET_KEY, next ? `⏱ ${schedules.length} schedule${schedules.length === 1 ? "" : "s"} · next ${formatTime(next.nextRunAt)}` : undefined);
87
+ return;
88
+ }
89
+ context.ui.setStatus(WIDGET_KEY, undefined);
90
+ if (schedules.length === 0) {
91
+ if (mountedWidget) {
92
+ context.ui.setWidget(WIDGET_KEY, undefined);
93
+ mountedWidget.tui.requestRender();
94
+ mountedWidget = undefined;
95
+ }
96
+ return;
97
+ }
98
+ if (mountedWidget) {
99
+ mountedWidget.component.invalidate();
100
+ mountedWidget.tui.requestRender();
101
+ return;
102
+ }
103
+ context.ui.setWidget(WIDGET_KEY, (tui, theme) => {
104
+ const component = new ScheduleWidget(() => scheduler.list(), theme);
105
+ mountedWidget = { component, tui };
106
+ return component;
107
+ }, { placement: "aboveEditor" });
108
+ };
109
+
110
+ const scheduler = new Scheduler({
111
+ clock: systemClock,
112
+ onChange: refreshUI,
113
+ onTrigger(schedule, source) {
114
+ pi.sendMessage({
115
+ customType: "scheduler-trigger",
116
+ content: `[Scheduled prompt: ${schedule.id}; source=${source}]\n${schedule.prompt}`,
117
+ display: true,
118
+ details: { scheduleId: schedule.id, source, every: schedule.every },
119
+ }, { triggerTurn: true, deliverAs: "followUp" });
120
+ },
121
+ });
122
+
123
+ const perform = (params: { action: ScheduleAction; every?: string; prompt?: string; id?: string }) => {
124
+ if (params.action === "add") {
125
+ if (!params.every || !params.prompt) throw new Error("add requires every and prompt");
126
+ const item = scheduler.add(params.every, params.prompt);
127
+ return `Created ${item.id}: every ${item.every}; next ${formatTime(item.nextRunAt)}.`;
128
+ }
129
+ if (params.action === "list") return formatList(scheduler.list());
130
+ if (params.action === "clear") {
131
+ const count = scheduler.clear();
132
+ return count === 0 ? "No active schedules to clear." : `Cleared ${count} schedule${count === 1 ? "" : "s"}. Already queued prompts cannot be withdrawn.`;
133
+ }
134
+ if (!params.id) throw new Error(`${params.action} requires id`);
135
+ if (params.action === "cancel") {
136
+ if (!scheduler.cancel(params.id)) throw new Error(`unknown schedule: ${params.id}`);
137
+ return `Cancelled ${params.id}. Already queued prompts cannot be withdrawn.`;
138
+ }
139
+ const result = scheduler.trigger(params.id);
140
+ if (!result.ok) {
141
+ if (result.reason === "already-pending") throw new Error(`${params.id} already has a pending prompt`);
142
+ throw new Error(`unknown schedule: ${params.id}`);
143
+ }
144
+ return `Triggered ${params.id}; its regular next time is unchanged.`;
145
+ };
146
+
147
+ pi.on("session_start", async (_event, ctx) => {
148
+ context = ctx;
149
+ refreshUI();
150
+ });
151
+ pi.on("message_start", async (event) => {
152
+ if (event.message.role !== "custom" || event.message.customType !== "scheduler-trigger") return;
153
+ const details = event.message.details as { scheduleId?: unknown } | undefined;
154
+ if (typeof details?.scheduleId === "string") scheduler.acknowledge(details.scheduleId);
155
+ });
156
+ pi.on("agent_settled", async () => {
157
+ // A queued follow-up can be cleared by Pi before it produces message_start.
158
+ // Once the run is fully settled, any still-pending scheduler message is gone.
159
+ scheduler.releaseUndelivered();
160
+ });
161
+ pi.on("session_shutdown", async (_event, ctx) => {
162
+ scheduler.clear();
163
+ if (ctx.hasUI) {
164
+ ctx.ui.setStatus(WIDGET_KEY, undefined);
165
+ if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, undefined);
166
+ }
167
+ mountedWidget = undefined;
168
+ context = undefined;
169
+ });
170
+
171
+ pi.registerTool({
172
+ name: "schedule",
173
+ label: "Schedule",
174
+ description: "Manage session-scoped interval prompts for the main agent. Use add when the user asks the agent to check something repeatedly, cancel for one schedule, and clear for all schedules. Use trigger for one immediate check without changing the regular cadence. Schedules end with the current session.",
175
+ parameters: Params,
176
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
177
+ async execute(_toolCallId, params) {
178
+ try {
179
+ const text = perform(params);
180
+ return { content: [{ type: "text" as const, text }], details: { ...params, schedules: scheduler.list() } };
181
+ } catch (error) {
182
+ const text = error instanceof Error ? error.message : String(error);
183
+ return { content: [{ type: "text" as const, text: `Error: ${text}` }], details: { ...params, error: text }, isError: true };
184
+ }
185
+ },
186
+ renderCall(args, theme) {
187
+ return new Text(`${theme.fg("toolTitle", theme.bold("schedule "))}${theme.fg("muted", args.action)}`, 0, 0);
188
+ },
189
+ renderResult(result, _options, theme) {
190
+ const item = result.content[0];
191
+ return new Text(theme.fg(result.isError ? "error" : "muted", item?.type === "text" ? item.text : ""), 0, 0);
192
+ },
193
+ });
194
+
195
+ pi.registerCommand("schedule", {
196
+ description: "Add, list, trigger, cancel, or clear session-scoped interval prompts",
197
+ handler: async (args, ctx) => {
198
+ try {
199
+ const text = perform(parseCommand(args));
200
+ ctx.ui.notify(text, "info");
201
+ } catch (error) {
202
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
203
+ }
204
+ },
205
+ });
206
+ }
@@ -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,173 @@
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(): number {
126
+ const count = this.schedules.size;
127
+ for (const schedule of this.schedules.values()) {
128
+ if (schedule.timer !== undefined) this.clock.clearTimeout(schedule.timer);
129
+ }
130
+ this.schedules.clear();
131
+ if (count > 0) this.onChange();
132
+ return count;
133
+ }
134
+
135
+ private arm(schedule: Schedule): void {
136
+ const delay = Math.max(0, schedule.nextRunAt - this.clock.now());
137
+ schedule.timer = this.clock.setTimeout(() => this.tick(schedule.id), delay);
138
+ }
139
+
140
+ private tick(id: string): void {
141
+ const schedule = this.schedules.get(id);
142
+ if (!schedule) return;
143
+ const now = this.clock.now();
144
+ do schedule.nextRunAt += schedule.intervalMs;
145
+ while (schedule.nextRunAt <= now);
146
+ if (!schedule.pending) this.fire(schedule, "timer");
147
+ this.arm(schedule);
148
+ this.onChange();
149
+ }
150
+
151
+ private fire(schedule: Schedule, source: "timer" | "manual"): void {
152
+ schedule.pending = true;
153
+ this.onTrigger(this.snapshot(schedule), source);
154
+ this.onChange();
155
+ }
156
+
157
+ private snapshot(schedule: Schedule): ScheduleSnapshot {
158
+ return {
159
+ id: schedule.id,
160
+ every: schedule.every,
161
+ intervalMs: schedule.intervalMs,
162
+ prompt: schedule.prompt,
163
+ nextRunAt: schedule.nextRunAt,
164
+ pending: schedule.pending,
165
+ };
166
+ }
167
+ }
168
+
169
+ export const systemClock: Clock = {
170
+ now: () => Date.now(),
171
+ setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
172
+ clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
173
+ };
@@ -0,0 +1,70 @@
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
+ const firstClear = scheduler.add("1h", "Check build");
63
+ const secondClear = scheduler.add("2h", "Check deployment");
64
+ assert.notEqual(firstClear.id, secondClear.id);
65
+ assert.equal(clock.timers.size, 2);
66
+ assert.equal(scheduler.clear(), 2);
67
+ assert.equal(clock.timers.size, 0, "clear cancels every timer");
68
+ assert.equal(scheduler.clear(), 0);
69
+ assert.ok(changes >= 5);
70
+ console.log("Scheduler core tests passed");
@@ -0,0 +1,117 @@
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 widgets = new Map([["herdr-subagents", { render: () => ["Herdr Fleet"] }]]);
10
+ const widgetOptions = new Map([["herdr-subagents", { placement: "belowEditor" }]]);
11
+ let renderRequests = 0;
12
+ const originalSetTimeout = globalThis.setTimeout;
13
+ const originalClearTimeout = globalThis.clearTimeout;
14
+ let nextTimer = 1;
15
+ const timers = new Map();
16
+ globalThis.setTimeout = (callback, delayMs) => {
17
+ const id = nextTimer++;
18
+ timers.set(id, { callback, delayMs });
19
+ return id;
20
+ };
21
+ globalThis.clearTimeout = (id) => timers.delete(id);
22
+ const pi = {
23
+ on(name, handler) { handlers.set(name, handler); },
24
+ registerTool(definition) { tool = definition; },
25
+ registerCommand(name, definition) { commands.set(name, definition); },
26
+ sendMessage(message, options) { messages.push({ message, options }); },
27
+ };
28
+ const childGuard = process.env.PI_HERDR_SUBAGENTS_CHILD;
29
+ delete process.env.PI_HERDR_SUBAGENTS_CHILD;
30
+ try {
31
+ registerScheduler(pi);
32
+ } finally {
33
+ if (childGuard === undefined) delete process.env.PI_HERDR_SUBAGENTS_CHILD;
34
+ else process.env.PI_HERDR_SUBAGENTS_CHILD = childGuard;
35
+ }
36
+ const notifications = [];
37
+ const asAppMessage = ({ message }) => ({ ...message, role: "custom" });
38
+ const context = {
39
+ hasUI: true,
40
+ mode: "tui",
41
+ ui: {
42
+ setStatus(key, value) { if (value === undefined) statuses.delete(key); else statuses.set(key, value); },
43
+ setWidget(key, value, options) {
44
+ if (value === undefined) {
45
+ widgets.delete(key);
46
+ widgetOptions.delete(key);
47
+ } else {
48
+ widgets.set(key, value({ requestRender() { renderRequests += 1; } }, { fg(_color, text) { return text; } }));
49
+ widgetOptions.set(key, options);
50
+ }
51
+ },
52
+ notify(message, level) { notifications.push({ message, level }); },
53
+ },
54
+ };
55
+ await handlers.get("session_start")({}, context);
56
+ assert.ok(tool);
57
+ assert.ok(commands.has("schedule"));
58
+ assert.deepEqual(tool.parameters.properties.action.enum, ["add", "list", "trigger", "cancel", "clear"]);
59
+
60
+ const add = await tool.execute("add", { action: "add", every: "1h", prompt: "Check CI" });
61
+ assert.match(add.content[0].text, /Created schedule-1/);
62
+ assert.equal(statuses.has("scheduler"), false, "scheduler no longer competes for footer status space");
63
+ assert.deepEqual(widgetOptions.get("scheduler"), { placement: "aboveEditor" });
64
+ assert.deepEqual(widgetOptions.get("herdr-subagents"), { placement: "belowEditor" });
65
+ assert.match(widgets.get("scheduler").render(100).join("\n"), /1 active schedule/);
66
+ assert.match(widgets.get("scheduler").render(100).join("\n"), /schedule-1 · 1h · next/);
67
+ assert.ok(widgets.has("herdr-subagents"), "Scheduler mounting preserves the Herdr Fleet widget");
68
+
69
+ const trigger = await tool.execute("trigger", { action: "trigger", id: "schedule-1" });
70
+ assert.match(trigger.content[0].text, /Triggered schedule-1/);
71
+ assert.equal(messages.length, 1);
72
+ assert.deepEqual(messages[0].options, { triggerTurn: true, deliverAs: "followUp" });
73
+ assert.match(messages[0].message.content, /\[Scheduled prompt: schedule-1; source=manual\]\nCheck CI/);
74
+ const duplicate = await tool.execute("trigger-again", { action: "trigger", id: "schedule-1" });
75
+ assert.equal(duplicate.isError, true);
76
+
77
+ await handlers.get("message_start")({ message: { role: "custom", customType: "other", details: { scheduleId: "schedule-1" } } });
78
+ assert.equal((await tool.execute("still-pending", { action: "trigger", id: "schedule-1" })).isError, true, "unrelated custom messages do not acknowledge a schedule");
79
+ await handlers.get("message_start")({ message: asAppMessage(messages[0]) });
80
+ await commands.get("schedule").handler("trigger schedule-1", context);
81
+ assert.equal(messages.length, 2);
82
+ assert.match(notifications.at(-1).message, /regular next time is unchanged/);
83
+ await commands.get("schedule").handler("list", context);
84
+ assert.match(notifications.at(-1).message, /schedule-1/);
85
+
86
+ await handlers.get("agent_settled")({ aborted: false });
87
+ await commands.get("schedule").handler("trigger schedule-1", context);
88
+ assert.equal(messages.length, 3, "settlement releases a queued scheduler message that never reached message_start");
89
+ await handlers.get("message_start")({ message: asAppMessage(messages[2]) });
90
+ await commands.get("schedule").handler("cancel schedule-1", context);
91
+ assert.match(notifications.at(-1).message, /Already queued prompts cannot be withdrawn/);
92
+ assert.equal(statuses.has("scheduler"), false);
93
+ assert.equal(widgets.has("scheduler"), false, "removing the final schedule removes the multi-line widget");
94
+ assert.ok(widgets.has("herdr-subagents"), "Scheduler cleanup preserves the Herdr Fleet widget");
95
+ await tool.execute("clear-add-one", { action: "add", every: "2h", prompt: "Check deployment" });
96
+ await tool.execute("clear-add-two", { action: "add", every: "3h", prompt: "Check release" });
97
+ assert.match(widgets.get("scheduler").render(100).join("\n"), /2 active schedules/);
98
+ assert.match(widgets.get("scheduler").render(100).join("\n"), /schedule-2/);
99
+ assert.match(widgets.get("scheduler").render(100).join("\n"), /schedule-3/);
100
+ await commands.get("schedule").handler("clear", context);
101
+ assert.match(notifications.at(-1).message, /Cleared 2 schedules/);
102
+ assert.equal(timers.size, 0, "clear cancels every active timer");
103
+ assert.equal(widgets.has("scheduler"), false, "clear removes the widget");
104
+ assert.ok(widgets.has("herdr-subagents"), "clear does not remove Herdr Fleet");
105
+ const emptyClear = await tool.execute("empty-clear", { action: "clear" });
106
+ assert.equal(emptyClear.content[0].text, "No active schedules to clear.");
107
+ await tool.execute("shutdown-add", { action: "add", every: "4h", prompt: "Check shutdown" });
108
+ assert.equal(timers.size, 1, "active schedule owns one timer");
109
+ await handlers.get("session_shutdown")({}, context);
110
+ assert.equal(statuses.has("scheduler"), false);
111
+ assert.equal(timers.size, 0, "session shutdown clears active timers");
112
+ assert.equal(widgets.has("scheduler"), false);
113
+ assert.ok(widgets.has("herdr-subagents"), "Scheduler shutdown does not remove Herdr Fleet");
114
+ assert.ok(renderRequests >= 4, "schedule changes request widget rerenders");
115
+ globalThis.setTimeout = originalSetTimeout;
116
+ globalThis.clearTimeout = originalClearTimeout;
117
+ console.log("Scheduler extension integration tests passed");