@maxiaochao/pi-toolkit 0.10.5 → 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 +12 -0
- package/README.md +27 -4
- package/package.json +7 -7
- package/packages/herdr-subagents/CHANGELOG.md +6 -0
- package/packages/herdr-subagents/README.md +12 -1
- package/packages/herdr-subagents/TESTING.md +4 -3
- package/packages/herdr-subagents/agents/analyst.md +8 -0
- package/packages/herdr-subagents/extensions/herdr-subagents.ts +1 -1
- package/packages/herdr-subagents/package.json +1 -1
- package/packages/herdr-subagents/src/parameters.ts +2 -2
- package/packages/herdr-subagents/src/status.ts +95 -0
- package/packages/herdr-subagents/src/tool.ts +13 -2
- package/packages/herdr-subagents/src/validation.ts +2 -1
- package/packages/herdr-subagents/tests/extension-contract.ts +4 -0
- package/packages/herdr-subagents/tests/run-tests.mjs +40 -2
- package/packages/scheduler/CHANGELOG.md +5 -0
- package/packages/scheduler/README.md +57 -0
- package/packages/scheduler/TESTING.md +19 -0
- package/packages/scheduler/docs/demo.gif +0 -0
- package/packages/scheduler/docs/screenshot.png +0 -0
- package/packages/scheduler/extensions/scheduler.ts +133 -0
- package/packages/{todo → scheduler}/package.json +7 -7
- package/packages/scheduler/src/scheduler.ts +171 -0
- package/packages/scheduler/tests/scheduler-core.mjs +63 -0
- package/packages/scheduler/tests/scheduler-extension.mjs +81 -0
- package/skills/agent-team/SKILL.md +33 -57
- package/skills/agent-team/models.json +19 -12
- package/packages/todo/CHANGELOG.md +0 -9
- package/packages/todo/README.md +0 -73
- package/packages/todo/TESTING.md +0 -22
- package/packages/todo/docs/demo.gif +0 -0
- package/packages/todo/docs/screenshot.png +0 -0
- package/packages/todo/extensions/todo.ts +0 -279
- package/packages/todo/src/todo-state.ts +0 -159
- package/packages/todo/tests/todo-extension.mjs +0 -134
- package/packages/todo/tests/todo-state.mjs +0 -108
|
@@ -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
|
+
}
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "@maxiaochao/pi-
|
|
2
|
+
"name": "@maxiaochao/pi-scheduler",
|
|
3
3
|
"version": "0.1.0",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "Session-scoped interval prompts for Pi agents.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"keywords": ["pi-package", "pi-extension", "
|
|
6
|
+
"keywords": ["pi-package", "pi-extension", "scheduler"],
|
|
7
7
|
"files": ["extensions", "src", "docs", "README.md", "TESTING.md", "CHANGELOG.md"],
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "git+https://github.com/chaochaoxiaochao/pi-toolkit.git",
|
|
11
|
-
"directory": "packages/
|
|
11
|
+
"directory": "packages/scheduler"
|
|
12
12
|
},
|
|
13
|
-
"pi": { "extensions": ["./extensions/
|
|
13
|
+
"pi": { "extensions": ["./extensions/scheduler.ts"] },
|
|
14
14
|
"peerDependencies": {
|
|
15
15
|
"@earendil-works/pi-ai": "*",
|
|
16
16
|
"@earendil-works/pi-coding-agent": "*",
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
"typebox": "*"
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
|
-
"test": "node --experimental-strip-types tests/
|
|
22
|
-
"load-test": "pi -ne -e ./extensions/
|
|
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
23
|
},
|
|
24
24
|
"publishConfig": { "access": "public" }
|
|
25
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");
|
|
@@ -4,86 +4,62 @@ description: 以对抗评审的方式让不同厂商的模型交叉验证同一
|
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
当前会话是**管理者**,不是转述人:自己也承担一份实证工作,并对每一轮的裁决负责。每轮并发启动三个相互独立的子代理,统一收集结果后由管理者裁决。子代理不需要常驻;跨轮连续性来自持久调查档案和逻辑成员身份,而不是聊天记忆。
|
|
8
8
|
|
|
9
9
|
## 何时值得开
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
只在用户要求组队时开。值得开的条件:结论会驱动一个代价高的动作(改内核、下线设备、向客户定责),或者证据链已经长到单模型容易自我确认。查一个事实、读一份文件用不上。
|
|
12
12
|
|
|
13
|
-
## 1.
|
|
13
|
+
## 1. 建立调查档案
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
为当前问题建立 `.pi/agent-team/<case>/`:
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
```text
|
|
18
|
+
.pi/agent-team/<case>/
|
|
19
|
+
├── BRIEF.md # 问题、范围、术语、输入和排除项
|
|
20
|
+
├── FINDINGS-stageN.md # 当前唯一事实源;新阶段整体取代旧阶段
|
|
21
|
+
├── LEDGER.md # 只增不改的 N/X 记录
|
|
22
|
+
└── rounds/
|
|
23
|
+
└── N.md # 本轮角色映射和可证伪攻击点
|
|
23
24
|
```
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
`BRIEF.md` 只记录稳定背景。`FINDINGS-stageN.md` 必含机制链、编号发现、已推翻清单和未闭合点。`LEDGER.md` 中发现按 `N1, N2…` 追加,被推翻的结论按 `X1, X2…` 追加并写明推翻者。只有管理者能更新这三个权威文件;子代理只提交候选证据。
|
|
26
27
|
|
|
27
|
-
## 2.
|
|
28
|
+
## 2. 配置成员与角色
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
名单在同目录的 `models.json`。三个逻辑成员必须来自不同模型厂商,并显式使用各自的 `model`;不能依赖父会话或环境默认模型。启动后核对并在本轮记录中写下每个任务实际生效的模型;无法确认、与名单不符或三个成员落到同一厂商时,本轮无效。
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
三个成员使用相同的中性只读执行约束,方法角色由每轮任务动态赋予:
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
- `auditor`:审源码、规范和提交,只接受可定位的直接证据。
|
|
35
|
+
- `replicator`:绕开管理者的脚本和口径,从原始数据独立复算。
|
|
36
|
+
- `adversary`:攻击前提和方法学,查样本敏感性、一对多计数和阴性对照。
|
|
34
37
|
|
|
35
|
-
|
|
36
|
-
管理者(本会话)
|
|
37
|
-
唯一写 FINDINGS 的人
|
|
38
|
-
│ │ │
|
|
39
|
-
下行:消息 │ │ │ herdr agent prompt
|
|
40
|
-
(短指针) ▼ ▼ ▼
|
|
41
|
-
┌────┐ ┌──────┐ ┌──────┐
|
|
42
|
-
│ sol│ │ grok │ │ opus │ 彼此不相连
|
|
43
|
-
└──┬──┘ └──┬───┘ └──┬───┘
|
|
44
|
-
│ │ │
|
|
45
|
-
上行:文件 └───────┴───────┘
|
|
46
|
-
▼
|
|
47
|
-
/tmp/<轮次>-<name>.md ← 管理者从磁盘读
|
|
48
|
-
```
|
|
38
|
+
模型与角色不得永久绑定。首轮可以按 `models.json` 顺序分配三个角色;后续根据争议轮换或交叉复核,使模型差异和角色差异能够被区分。每轮在 `rounds/N.md` 写明成员、模型、角色、输入文件和攻击点。
|
|
49
39
|
|
|
50
|
-
|
|
40
|
+
## 3. 交底与通信
|
|
51
41
|
|
|
52
|
-
|
|
42
|
+
拓扑是星型,管理者是唯一消息路由。成员在同一轮中独立作答,在提交前不得看到其他成员本轮答案。需要逻辑成员 A 反驳逻辑成员 B 的上一轮结论时,由管理者在新任务中引用 B 的原话和证据位置;不能假定新实例记得上一轮聊天。
|
|
53
43
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
```bash
|
|
57
|
-
MSG='【N17】读 /tmp/FINDINGS-stage4.md 全文。本轮你的攻击点:……。结论写 /tmp/N17-<你的名字>.md,终端只回一行路径。'
|
|
58
|
-
for a in sol grok opus; do herdr agent prompt "$a" "$MSG"; done
|
|
59
|
-
for a in sol grok opus; do herdr agent wait "$a" --until idle --timeout 900000; done
|
|
60
|
-
```
|
|
44
|
+
下行只给短任务和文件指针:每个成员阅读 `BRIEF.md`、最新 `FINDINGS-stageN.md`、`LEDGER.md` 和本轮文件。不得要求它重新梳理已经确认的全部背景。上行结果必须能归属到轮次和成员,并包含证据位置;长结果写入持久文件,不能依赖截断的终端文本。
|
|
61
45
|
|
|
62
46
|
## 4. 回合
|
|
63
47
|
|
|
64
|
-
一轮 =
|
|
48
|
+
一轮 = 记录受保护文件和工作树初始状态 → 写本轮任务 → 三个成员并发独立调查 → 管理者收齐结果 → 检查子代理未修改项目或权威文件 → 核验证据并裁决 → 更新 FINDINGS 和 LEDGER → 决定下一轮。
|
|
65
49
|
|
|
66
|
-
|
|
50
|
+
每个成员必须收到具体到能证伪的攻击点,例如“查 `adbconnection.cc`,确认该错误日志与被销毁的 fd 是否 1:1”,而不是“看看有没有问题”。子代理结束后即可释放;下一轮通过调查档案恢复事实状态,并重新创建独立任务。
|
|
67
51
|
|
|
68
52
|
## 5. 纪律
|
|
69
53
|
|
|
70
|
-
-
|
|
71
|
-
-
|
|
72
|
-
- **
|
|
73
|
-
- **
|
|
74
|
-
- **
|
|
75
|
-
-
|
|
54
|
+
- **独立性**:同轮答案不得互相可见;成员之间不直接通信。
|
|
55
|
+
- **只读完整性**:只读是协议约束,不假定执行环境构成沙箱。每轮结束后比较初始状态;发现子代理修改项目或权威文件时,该成员结果无效并显式报告污染范围。
|
|
56
|
+
- **retraction**:管理者被驳倒时,在下一轮开头明确写“我错了,更正为……”,并追加对应 X。
|
|
57
|
+
- **replication**:任何承载结论的数字至少由两个不同模型独立计算;对不上先查口径再查代码。
|
|
58
|
+
- **control**:任何“异常”指标必须配阴性样本才进入 FINDINGS。
|
|
59
|
+
- **grading**:每条结论标注 `源码读取 / 实测 / 推断` 和置信度;推断不能与实测并列陈述。
|
|
60
|
+
- **裁决**:成员冲突时管理者必须判定谁对并说明证据,不能多数投票或折中平均。
|
|
61
|
+
- **缺席视角**:成员失败或额度耗尽时,明确记录缺失的模型和方法角色,不能把不完整轮次当成完整评审。
|
|
76
62
|
|
|
77
63
|
## 6. 收尾
|
|
78
64
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
最终文档必须单列**未闭合点**,以及**每一个需要人或设备批准才能执行的动作**。
|
|
82
|
-
|
|
83
|
-
用完 `herdr pane close <pane_id>` 关掉,pane 不会自己退出。
|
|
84
|
-
|
|
85
|
-
## 故障
|
|
86
|
-
|
|
87
|
-
- pane 额度耗尽:降级为剩余 pane 继续,并在文档里记下缺失的是哪个视角。
|
|
88
|
-
- agent 卡在 blocked:`herdr agent read <name> --source visible` 看它在等什么,多半是在等一个确认。
|
|
89
|
-
- 广播后无人响应:`herdr agent list` 核对 pane 是否还活着,pane id 会随布局变化。
|
|
65
|
+
收尾前必须进行一轮**开放攻击**:三个方法角色各自选择其认为最强的攻击点,不能只回答管理者预设的窄问题。只有完整开放攻击轮没有产生任何被管理者接受并记入 X 的新反证时才算收敛。最终文档单列未闭合点、缺失视角,以及每一个需要人或设备批准才能执行的动作。
|
|
@@ -1,25 +1,32 @@
|
|
|
1
1
|
{
|
|
2
|
-
"defaults": {
|
|
3
|
-
"kind": "pi",
|
|
4
|
-
"thinking": "high"
|
|
5
|
-
},
|
|
6
2
|
"members": [
|
|
7
3
|
{
|
|
8
4
|
"name": "sol",
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"charter": "只接受能逐行引用的源码、规范、commit,输出必须带 文件:行号。负责把「机制上可能」变成「代码里确实如此」;查不到就说查不到,不要用常识补。"
|
|
5
|
+
"vendor": "OpenAI",
|
|
6
|
+
"model": "pudu/gpt-5.6-sol"
|
|
12
7
|
},
|
|
13
8
|
{
|
|
14
9
|
"name": "grok",
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"charter": "拿原始数据和口径,不看管理者的脚本,独立重算同一个指标,只报自己的数和差异。数对不上就是一次发现,先查口径再查代码。"
|
|
10
|
+
"vendor": "xAI",
|
|
11
|
+
"model": "pudu/grok-4.6"
|
|
18
12
|
},
|
|
19
13
|
{
|
|
20
14
|
"name": "opus",
|
|
21
|
-
"
|
|
22
|
-
"
|
|
15
|
+
"vendor": "Anthropic",
|
|
16
|
+
"model": "pudu/claude-opus-5"
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"roles": [
|
|
20
|
+
{
|
|
21
|
+
"name": "auditor",
|
|
22
|
+
"charter": "只接受能逐行引用的源码、规范、commit,输出必须带文件和行号。负责把“机制上可能”变成“代码里确实如此”;查不到就说查不到,不要用常识补。"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "replicator",
|
|
26
|
+
"charter": "拿原始数据和口径,不看管理者的脚本,独立重算同一个指标,只报自己的数和差异。数对不上就是一次发现,先查口径再查代码。"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "adversary",
|
|
23
30
|
"charter": "攻前提和方法学,不攻结论。指出指标对样本组成的敏感性、计数的一对多、缺失的阴性对照。附议没有价值,找不到问题就说明理由。"
|
|
24
31
|
}
|
|
25
32
|
]
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## Unreleased
|
|
4
|
-
|
|
5
|
-
- replace generic Todo CRUD with serialized execution-plan lifecycle commands, guarded single-step advancement, preserved replan history, canonical current-plan results, branch reconstruction tests, and a live rerendering current-plan widget
|
|
6
|
-
|
|
7
|
-
## 2026-09-30 - v0.1.0
|
|
8
|
-
|
|
9
|
-
- 从 `@maxiaochao/pi-toolkit` 提取 todo 权威源码,支持主包捆绑和独立安装。
|