pi-roundtable 0.7.10 → 0.7.12

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
@@ -5,6 +5,26 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.7.12] - 2026-10-03
9
+
10
+ ### Fixed
11
+
12
+ - pi-roundtable-sandbox: a host compactor's time shrinks with the turn's, and an error event in the middle of a successful model stream is logged; see its changelog.
13
+
14
+ ## [0.7.11] - 2026-10-03
15
+
16
+ ### Added
17
+
18
+ - Precheck scripts: agents may write a schedule's precheck themselves. `schedule_create` and `schedule_update` take `precheck_script`, a JavaScript module of at most `PRECHECK_SCRIPT_CHARS` (8,000) characters with a default export, parsed but never run when it is set; a schedule has a `precheck` or a `precheck_script`, and setting one removes the other. The core never runs a script: a `PrecheckScriptRunner` registered with `PRECHECKS.useScriptRunner` (`scriptRunner` reads it) runs it with a `PrecheckScriptContext` (the schedule, `firedAt`, `signal`, the host's `timeZone`, and `today`), and its `describe` (given a `PrecheckScope`) tells the model what a script may call; `schedule_list` shows it and a schedule's script. Without a runner, the tools neither take nor mention scripts, and a stored script wakes the turn with `### Precheck failed: script`. `Schedule`, `NewSchedule`, and `ScheduleChange` gain `precheckScript`; the `schedules` table gains a nullable `precheck_script` column through the new `schedules-precheck-script` migration. `scheduleToolSpecs` takes `precheckScripts`.
19
+ - `pi-roundtable/testing`: `fakeScriptRunner` (`fakeScriptRunner(answer, options?)`), with the types `FakeScriptAnswer` and `FakeScriptRunner`.
20
+ - `PrecheckScope` carries `tier`: the script's creator's tier when it runs, the asker's when `schedule_list` describes it, so a runner may grant lower tiers less. A script may export its default as `export { check as default }`. The store itself keeps a schedule's `precheck` and `precheckScript` apart. `schedule_list` waits at most 10 seconds for the runner's `describe`.
21
+
22
+ ### Changed
23
+
24
+ - Stopping the scheduler aborts running prechecks (their `signal` fires) and waits up to 15 seconds for them to settle, so a sandboxed script's container is removed before the host exits; a precheck that ends after the stop starts no turn and records `skipped: the host stopped during its precheck`.
25
+ - Behavior change: `PrecheckRegistry` gains `useScriptRunner` and `scriptRunner`, so a host that provides `PRECHECKS` with a registry of its own must add them.
26
+ - pi-roundtable-sandbox: `precheckScriptRunner` runs each script in a sealed container; see its changelog.
27
+
8
28
  ## [0.7.10] - 2026-10-03
9
29
 
10
30
  ### Added
package/docs/plugins.md CHANGED
@@ -167,7 +167,7 @@ The built-in plugins provide these, from the main entry:
167
167
  | `AGENTS` | `AgentServer` | `agent-server` | The `team` (`AgentTeam`), the read-only `directory` (`AgentDirectory`), the `runtime` every agent turn runs on, `approvals` (whether the owner's reply approves held actions), and `avatars` (`AvatarStudio`) |
168
168
  | `SKILLS` | `SkillRegistry` | `skills` (an addon) | What agents carry: `carried`, `carriedNames`, `describeCarried`, `catalog`, `list`, `linkedFrom`, `checkRegistered`, `link`, `attach` |
169
169
  | `SCHEDULES` | `ScheduleStore` | `schedule-store` | The stored schedules: `create`, `get`, `forChannel`, `all`, `update`, `remove`, `due`, `claim`, `recordStatus` |
170
- | `PRECHECKS` | `PrecheckRegistry` | `prechecks` | The host's named [prechecks](#prechecks-wake-a-schedule-only-when-it-has-work): `register`, `get`, `list` |
170
+ | `PRECHECKS` | `PrecheckRegistry` | `prechecks` | The host's named [prechecks](#prechecks-wake-a-schedule-only-when-it-has-work): `register`, `get`, `list`; and the runner of agents' precheck scripts: `useScriptRunner`, `scriptRunner` |
171
171
  | `MEMORY` | `MemoryStore` | `memory` (an addon) | `forSpeaker(id)` gives that speaker's `SpeakerMemory`: `list`, `forPrompt`, `add`, `search`, `update`, `removeById`, `remove`; `MEMORY_KINDS` is `core`, `note`, `event` |
172
172
  | `BACKGROUND_TURNS` | `BackgroundTurns` | `modules` | Turns nobody wrote: `runScheduled`, `runDelegated`, `runErrorReport` |
173
173
  | `DELEGATION` | `Delegator` | `modules` | `start(request)` a background task, `runningChannels()`, `idle()` |
@@ -800,6 +800,19 @@ export function recoveryPrecheck(read: () => Promise<RecoveryReading>) {
800
800
  ```
801
801
  <!-- /example -->
802
802
 
803
+ ##### Precheck scripts: prechecks the agent writes
804
+
805
+ A host can also let agents write a schedule's precheck themselves, so a new check needs no change to the host.
806
+ The core stores the script with the schedule and decides with it exactly as with a named precheck, but it never runs a script itself: a `PrecheckScriptRunner` does, registered once with `services.get(PRECHECKS).useScriptRunner(runner)`.
807
+ pi-roundtable-sandbox's `precheckScriptRunner` runs each script in a sealed container whose only way out is the MCP tools the host grants for that schedule; see its README.
808
+
809
+ - `schedule_create` and `schedule_update` take `precheck_script`, a JavaScript module of at most `PRECHECK_SCRIPT_CHARS` (8,000) characters with a default export; it is parsed, never run, when it is set. A schedule has a `precheck` or a `precheck_script`; setting one removes the other, and `null` removes either.
810
+ - The runner's `run(script, context)` gets the `PrecheckScriptContext`: the schedule, `firedAt`, `signal`, the host's `timeZone`, and `today`, the date there. Its answer is checked like a named precheck's, and its finding is named `script`.
811
+ - `describe({ channel, target, tier })` (a `PrecheckScope`) tells the model how to write one and what it may call there; `schedule_list` shows it, waiting at most 10 seconds. `tier` is the asker's there and the script's creator's when it runs, so a runner may grant lower tiers less.
812
+ - When the host stops, the scheduler aborts running scripts and waits up to 15 seconds for the runner to clean up; a precheck that ends then starts no turn.
813
+ - Without a runner, the tools neither take nor mention `precheck_script`, a script is refused with the registered names, and a schedule that already has one wakes with `### Precheck failed: script`, never a silent skip.
814
+
815
+
803
816
  ### `migrations` and `context.database()`: tables of your own
804
817
 
805
818
  Declare `migrations` on the plugin object.
@@ -2135,6 +2148,7 @@ Call `useTestLocale()` after a test changes the process-wide locale or time zone
2135
2148
  Use `partial<Port>({ ... })` to stand in for a port your code takes as an argument.
2136
2149
  It provides the members you give it and throws an error naming any missing member you read, so the test needs no `as unknown as Port` cast.
2137
2150
  `fakePrecheck(name, answer, { description?, timeoutMs? })` is a precheck that answers `answer` (a `PrecheckResult`, an `Error` it throws, or a function of its context) and records each context in `calls`; `fakePrechecks(...prechecks)` is a real in-memory `PrecheckRegistry` with them registered, which a test gives a plugin as `servicePair(PRECHECKS, registry)` and reads back with `registry.get(name)`.
2151
+ `fakeScriptRunner(answer, { describe?, timeoutMs? })` is a `PrecheckScriptRunner` that runs nothing and answers `answer` (a result, an `Error` it throws, or a function of the script and its context), recording each call in `calls`; give it to `registry.useScriptRunner`.
2138
2152
  `fakeDiscord({ ownerId?, rootCommand? })` is the `DISCORD` service for a plugin that adds slash commands: give it as `services: [discord.service]`, read what the plugin added with `discord.added()`, and compose the tree Discord would get with `discord.compose()`.
2139
2153
  Only `commands` and `guard` are given; a plugin that reads another member of `DISCORD` in a test gives its own with `servicePair(DISCORD, { ... })`.
2140
2154
 
@@ -2507,6 +2521,10 @@ Import from the entries listed below; source area files are internal.
2507
2521
  | `PrecheckFinding` | `pi-roundtable` | type |
2508
2522
  | `PrecheckRegistry` | `pi-roundtable` | type |
2509
2523
  | `PrecheckResult` | `pi-roundtable` | type |
2524
+ | `PrecheckScope` | `pi-roundtable` | type |
2525
+ | `PrecheckScriptContext` | `pi-roundtable` | type |
2526
+ | `PrecheckScriptRunner` | `pi-roundtable` | type |
2527
+ | `PRECHECK_SCRIPT_CHARS` | `pi-roundtable` | value |
2510
2528
  | `SKILLS` | `pi-roundtable` | value |
2511
2529
  | `Schedule` | `pi-roundtable` | type |
2512
2530
  | `ScheduleChange` | `pi-roundtable` | type |
@@ -2585,6 +2603,9 @@ Import from the entries listed below; source area files are internal.
2585
2603
  | `fakePrechecks` | `pi-roundtable/testing` | value |
2586
2604
  | `FakePrecheck` | `pi-roundtable/testing` | type |
2587
2605
  | `FakePrecheckAnswer` | `pi-roundtable/testing` | type |
2606
+ | `fakeScriptRunner` | `pi-roundtable/testing` | value |
2607
+ | `FakeScriptAnswer` | `pi-roundtable/testing` | type |
2608
+ | `FakeScriptRunner` | `pi-roundtable/testing` | type |
2588
2609
  | `fakeThreads` | `pi-roundtable/testing` | value |
2589
2610
  | `openTestStore` | `pi-roundtable/testing` | value |
2590
2611
  | `servicePair` | `pi-roundtable/testing` | value |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.7.10",
3
+ "version": "0.7.12",
4
4
  "description": "A plugin-driven Pi agent server for Discord",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -78,7 +78,9 @@ function section(schedule: Schedule, label: TargetLabel): string {
78
78
  text.scheduleSetBy(plain(schedule.createdByName), last),
79
79
  ...(schedule.precheck
80
80
  ? [text.schedulePrecheck(plain(schedule.precheck))]
81
- : []),
81
+ : schedule.precheckScript
82
+ ? [text.schedulePrecheckScript(schedule.precheckScript.length)]
83
+ : []),
82
84
  `-# ${plain(prompt)}`,
83
85
  ].join("\n");
84
86
  }
@@ -49,6 +49,8 @@ export function schedulesEn(ctx: CatalogContext) {
49
49
  scheduleSetBy: (name: string, lastRun: string) =>
50
50
  `Set by ${name}${lastRun}`,
51
51
  schedulePrecheck: (name: string) => `Checked first by precheck ${name}`,
52
+ schedulePrecheckScript: (chars: number) =>
53
+ `Checked first by its own precheck script (${chars} characters)`,
52
54
  schedulePrecheckNote: (id: number, title: string, note: string) =>
53
55
  `-# Schedule #${id} ${title}, skipped by its precheck: ${note.replace(/\n+/g, "\n-# ")}`,
54
56
  scheduleChoice: (id: number, title: string, recurrence: string) =>
@@ -91,6 +93,8 @@ export function schedulesZhTW(
91
93
  scheduleSetBy: (name: string, lastRun: string) =>
92
94
  `由 ${name} 設定${lastRun}`,
93
95
  schedulePrecheck: (name: string) => `先由預檢 ${name} 判斷`,
96
+ schedulePrecheckScript: (chars: number) =>
97
+ `先由自訂的預檢腳本判斷(${chars} 字元)`,
94
98
  schedulePrecheckNote: (id: number, title: string, note: string) =>
95
99
  `-# 排程 #${id} ${title} 經預檢略過:${note.replace(/\n+/g, "\n-# ")}`,
96
100
  scheduleChoice: (id: number, title: string, recurrence: string) =>
@@ -1,9 +1,19 @@
1
+ import { parse } from "@babel/parser";
2
+ import type { ChannelKey } from "../../domain/conversation.ts";
3
+ import { ScheduleError } from "../../domain/errors.ts";
1
4
  import { PluginError } from "../../errors.ts";
5
+ import type { Tier } from "../../speakers.ts";
2
6
  import type { Schedule } from "./schedule-store.ts";
3
7
 
4
8
  /** How long a precheck may run before it counts as failed, unless it sets its own `timeoutMs`. */
5
9
  export const PRECHECK_TIMEOUT_MS = 60_000;
6
10
 
11
+ /** The longest precheck script a schedule may carry, in characters. */
12
+ export const PRECHECK_SCRIPT_CHARS = 8_000;
13
+
14
+ /** What a finding names a schedule's own script by, in its heading and status. */
15
+ export const SCRIPT_PRECHECK = "script";
16
+
7
17
  /** A precheck name: lower-case letters, digits, `.`, `_`, and `-`, starting with a letter or digit. */
8
18
  const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
9
19
 
@@ -25,7 +35,7 @@ export interface PrecheckContext {
25
35
  /** The due schedule, already moved to its next run (or deleted, when it runs once). */
26
36
  schedule: Schedule;
27
37
  firedAt: Date;
28
- /** Aborted when the precheck runs out of time; its answer is then ignored. */
38
+ /** Aborted when the precheck runs out of time or the host stops; its answer is then ignored. */
29
39
  signal: AbortSignal;
30
40
  }
31
41
 
@@ -45,13 +55,57 @@ export interface Precheck {
45
55
  run(context: PrecheckContext): PrecheckResult | Promise<PrecheckResult>;
46
56
  }
47
57
 
48
- /** The host's prechecks, by name. Provided as `PRECHECKS` by the `prechecks` plugin. */
58
+ /** Whose schedule a script is for: the runner decides what such a script may reach from it. */
59
+ export interface PrecheckScope {
60
+ channel: ChannelKey;
61
+ /** The schedule's background target, such as the owner's. */
62
+ target: string;
63
+ /**
64
+ * The tier of whoever set the script: its creator's when it runs, the asker's when schedule_list
65
+ * describes it; absent when the asker's is unknown. A runner may grant lower tiers less.
66
+ */
67
+ tier?: Tier;
68
+ }
69
+
70
+ /** What a schedule's precheck script runs with when its schedule falls due. */
71
+ export interface PrecheckScriptContext extends PrecheckContext {
72
+ /** The host's IANA time zone, such as `Asia/Taipei`. */
73
+ timeZone: string;
74
+ /** The date in that zone when it fired, `YYYY-MM-DD`, so a script never reads a UTC date by mistake. */
75
+ today: string;
76
+ }
77
+
78
+ /**
79
+ * Runs the precheck scripts agents write for their schedules, isolated from the host: the core
80
+ * stores, checks, and schedules them but never runs one itself. Provided by a sandbox, such as
81
+ * pi-roundtable-sandbox's `precheckScriptRunner`, through `PRECHECKS.useScriptRunner`.
82
+ */
83
+ export interface PrecheckScriptRunner {
84
+ /** Runs one script; its answer is checked like a host precheck's, and a throw wakes the turn with the error. */
85
+ run(
86
+ script: string,
87
+ context: PrecheckScriptContext,
88
+ ): PrecheckResult | Promise<PrecheckResult>;
89
+ /** How long a script may run; default 60 seconds. A timeout counts as a throw. */
90
+ timeoutMs?: number;
91
+ /**
92
+ * How to write a script for a schedule in this scope, and what it may call there, shown to the
93
+ * model by schedule_list.
94
+ */
95
+ describe(scope: PrecheckScope): string | Promise<string>;
96
+ }
97
+
98
+ /** The host's prechecks, by name, and the runner of agents' precheck scripts. Provided as `PRECHECKS` by the `prechecks` plugin. */
49
99
  export interface PrecheckRegistry {
50
100
  /** Adds a precheck; throws PluginError for a bad or repeated name, an empty description, or a bad timeout. */
51
101
  register(precheck: Precheck): void;
52
102
  get(name: string): Precheck | undefined;
53
103
  /** Every registered precheck, by name. */
54
104
  list(): readonly Precheck[];
105
+ /** Lets agents attach scripts of their own, run by this runner; throws PluginError when one is set already. */
106
+ useScriptRunner(runner: PrecheckScriptRunner): void;
107
+ /** The runner of precheck scripts; undefined when no sandbox provides one, and then no script is accepted. */
108
+ scriptRunner(): PrecheckScriptRunner | undefined;
55
109
  }
56
110
 
57
111
  /** What came of a precheck that let the turn run, carried into the turn's text. */
@@ -71,7 +125,30 @@ export type PrecheckOutcome =
71
125
  */
72
126
  export function memoryPrecheckRegistry(): PrecheckRegistry {
73
127
  const prechecks = new Map<string, Precheck>();
128
+ let runner: PrecheckScriptRunner | undefined;
74
129
  return {
130
+ useScriptRunner(given) {
131
+ if (runner)
132
+ throw new PluginError(
133
+ "a precheck script runner is set already; register one sandbox to run scripts.",
134
+ );
135
+ if (
136
+ typeof given?.run !== "function" ||
137
+ typeof given.describe !== "function"
138
+ )
139
+ throw new PluginError(
140
+ "a precheck script runner needs run and describe functions.",
141
+ );
142
+ if (
143
+ given.timeoutMs !== undefined &&
144
+ !(Number.isInteger(given.timeoutMs) && given.timeoutMs > 0)
145
+ )
146
+ throw new PluginError(
147
+ `a precheck script runner's timeoutMs must be a positive whole number of milliseconds; got ${String(given.timeoutMs)}.`,
148
+ );
149
+ runner = given;
150
+ },
151
+ scriptRunner: () => runner,
75
152
  register(precheck) {
76
153
  const { name, description, timeoutMs, run } = precheck ?? {};
77
154
  if (typeof name !== "string" || !NAME.test(name))
@@ -103,6 +180,47 @@ export function memoryPrecheckRegistry(): PrecheckRegistry {
103
180
  };
104
181
  }
105
182
 
183
+ /**
184
+ * Checks a precheck script without running it: a JavaScript module within the size limit whose
185
+ * default export is what the runner calls. Throws ScheduleError with what to fix.
186
+ */
187
+ export function checkPrecheckScript(script: unknown): string {
188
+ if (typeof script !== "string" || !script.trim())
189
+ throw new ScheduleError(
190
+ "precheck_script must be a JavaScript module with a default export",
191
+ );
192
+ if (script.length > PRECHECK_SCRIPT_CHARS)
193
+ throw new ScheduleError(
194
+ `precheck_script is ${script.length} characters; keep it within ${PRECHECK_SCRIPT_CHARS}`,
195
+ );
196
+ let program: ReturnType<typeof parse>["program"];
197
+ try {
198
+ program = parse(script, {
199
+ sourceType: "module",
200
+ errorRecovery: false,
201
+ }).program;
202
+ } catch (error) {
203
+ throw new ScheduleError(
204
+ `precheck_script does not parse as a JavaScript module: ${errorText(error)}`,
205
+ );
206
+ }
207
+ const exportsDefault = program.body.some(
208
+ (node) =>
209
+ node.type === "ExportDefaultDeclaration" ||
210
+ (node.type === "ExportNamedDeclaration" &&
211
+ node.specifiers.some((specifier) =>
212
+ specifier.exported.type === "Identifier"
213
+ ? specifier.exported.name === "default"
214
+ : specifier.exported.value === "default",
215
+ )),
216
+ );
217
+ if (!exportsDefault)
218
+ throw new ScheduleError(
219
+ "precheck_script needs a default export: export default async (context) => ({ wake: false, note }) or ({ wake: true, context })",
220
+ );
221
+ return script;
222
+ }
223
+
106
224
  function errorText(error: unknown): string {
107
225
  return error instanceof Error ? error.message || error.name : String(error);
108
226
  }
@@ -128,10 +246,17 @@ function decided(result: unknown): PrecheckOutcome {
128
246
  export async function runPrecheck(
129
247
  precheck: Precheck,
130
248
  context: Omit<PrecheckContext, "signal">,
249
+ options: {
250
+ /** Aborts the run early, such as when the host stops; it then fails with `stopped`. */
251
+ signal?: AbortSignal;
252
+ /** Receives the run itself, which may outlast the answer after a timeout, so a caller can await its cleanup. */
253
+ settled?: (run: Promise<unknown>) => void;
254
+ } = {},
131
255
  ): Promise<PrecheckOutcome> {
132
256
  const timeoutMs = precheck.timeoutMs ?? PRECHECK_TIMEOUT_MS;
133
257
  const abort = new AbortController();
134
258
  let timer: ReturnType<typeof setTimeout> | undefined;
259
+ let stop: (() => void) | undefined;
135
260
  const timeout = new Promise<PrecheckOutcome>((resolve) => {
136
261
  timer = setTimeout(() => {
137
262
  abort.abort();
@@ -140,6 +265,12 @@ export async function runPrecheck(
140
265
  error: `it did not answer within ${timeoutMs / 1000} seconds`,
141
266
  });
142
267
  }, timeoutMs);
268
+ stop = () => {
269
+ abort.abort();
270
+ resolve({ kind: "failed", error: "stopped: the host is shutting down" });
271
+ };
272
+ if (options.signal?.aborted) stop();
273
+ else options.signal?.addEventListener("abort", stop, { once: true });
143
274
  });
144
275
  const answer = (async (): Promise<PrecheckOutcome> => {
145
276
  try {
@@ -148,9 +279,11 @@ export async function runPrecheck(
148
279
  return { kind: "failed", error: `it threw: ${errorText(error)}` };
149
280
  }
150
281
  })();
282
+ options.settled?.(answer);
151
283
  try {
152
284
  return await Promise.race([answer, timeout]);
153
285
  } finally {
154
286
  clearTimeout(timer);
287
+ if (stop) options.signal?.removeEventListener("abort", stop);
155
288
  }
156
289
  }
@@ -1,6 +1,7 @@
1
1
  import type { SQL } from "bun";
2
2
  import type { Migration } from "../../db/migrations.ts";
3
3
  import type { ChannelKey } from "../../domain/conversation.ts";
4
+ import { ScheduleError } from "../../domain/errors.ts";
4
5
  import type { ScheduleStore } from "../../services.ts";
5
6
  import type { Tier } from "../../speakers.ts";
6
7
  import type { Recurrence } from "./recurrence.ts";
@@ -21,6 +22,8 @@ export interface Schedule {
21
22
  createdAt: Date;
22
23
  /** The name of the precheck the host runs before its turn; absent, the turn always runs. */
23
24
  precheck?: string;
25
+ /** The precheck script an agent wrote for it, run in the host's sandbox; a schedule has a name or a script, not both. */
26
+ precheckScript?: string;
24
27
  lastRun?: Date;
25
28
  lastStatus?: string;
26
29
  }
@@ -37,6 +40,8 @@ export interface NewSchedule {
37
40
  createdTier: Tier;
38
41
  /** A registered precheck's name, run before each turn. */
39
42
  precheck?: string;
43
+ /** A precheck script, run in the host's sandbox before each turn; not with `precheck`. */
44
+ precheckScript?: string;
40
45
  }
41
46
 
42
47
  export interface ScheduleChange {
@@ -46,6 +51,8 @@ export interface ScheduleChange {
46
51
  nextRun?: Date;
47
52
  /** A registered precheck's name to run before each turn; null removes the schedule's precheck. */
48
53
  precheck?: string | null;
54
+ /** A precheck script to run before each turn; null removes it. */
55
+ precheckScript?: string | null;
49
56
  }
50
57
 
51
58
  interface Row {
@@ -62,6 +69,7 @@ interface Row {
62
69
  created_tier: Tier;
63
70
  created_at: Date;
64
71
  precheck: string | null;
72
+ precheck_script: string | null;
65
73
  last_run: Date | null;
66
74
  last_status: string | null;
67
75
  }
@@ -81,6 +89,7 @@ function toSchedule(row: Row): Schedule {
81
89
  createdTier: row.created_tier,
82
90
  createdAt: row.created_at,
83
91
  ...(row.precheck ? { precheck: row.precheck } : {}),
92
+ ...(row.precheck_script ? { precheckScript: row.precheck_script } : {}),
84
93
  ...(row.last_run ? { lastRun: row.last_run } : {}),
85
94
  ...(row.last_status ? { lastStatus: row.last_status } : {}),
86
95
  };
@@ -120,7 +129,7 @@ export class PgScheduleStore implements ScheduleStore {
120
129
  },
121
130
  };
122
131
 
123
- /** The table, then the precheck each schedule may name; schedules made before prechecks have none. */
132
+ /** The table, then the precheck each schedule may name or carry; schedules made before prechecks have none. */
124
133
  static migrations(): Migration[] {
125
134
  return [
126
135
  PgScheduleStore.migration,
@@ -130,6 +139,12 @@ export class PgScheduleStore implements ScheduleStore {
130
139
  await sql`ALTER TABLE schedules ADD COLUMN IF NOT EXISTS precheck text`;
131
140
  },
132
141
  },
142
+ {
143
+ name: "schedules-precheck-script",
144
+ up: async (sql) => {
145
+ await sql`ALTER TABLE schedules ADD COLUMN IF NOT EXISTS precheck_script text`;
146
+ },
147
+ },
133
148
  ];
134
149
  }
135
150
 
@@ -139,13 +154,17 @@ export class PgScheduleStore implements ScheduleStore {
139
154
  }
140
155
 
141
156
  async create(schedule: NewSchedule): Promise<Schedule> {
157
+ if (schedule.precheck && schedule.precheckScript)
158
+ throw new ScheduleError(
159
+ "a schedule has a precheck or a precheck script, not both",
160
+ );
142
161
  const rows: Row[] = await this.#sql`
143
162
  INSERT INTO schedules (channel_key, mode, title, prompt, recurrence, next_run,
144
- created_by_id, created_by_name, created_tier, precheck)
163
+ created_by_id, created_by_name, created_tier, precheck, precheck_script)
145
164
  VALUES (${schedule.channel}, ${schedule.target}, ${schedule.title}, ${schedule.prompt},
146
165
  ${JSON.stringify(schedule.recurrence)}, ${schedule.nextRun},
147
166
  ${schedule.createdById}, ${schedule.createdByName}, ${schedule.createdTier},
148
- ${schedule.precheck ?? null})
167
+ ${schedule.precheck ?? null}, ${schedule.precheckScript ?? null})
149
168
  RETURNING *`;
150
169
  const [row] = rows;
151
170
  if (!row) throw new Error("the schedule insert returned no row");
@@ -178,13 +197,29 @@ export class PgScheduleStore implements ScheduleStore {
178
197
  ): Promise<Schedule | undefined> {
179
198
  const current = await this.get(id);
180
199
  if (!current || current.channel !== channel) return undefined;
200
+ if (change.precheck && change.precheckScript)
201
+ throw new ScheduleError(
202
+ "a schedule has a precheck or a precheck script, not both",
203
+ );
204
+ // Setting one removes the other, so the scheduler never has to pick.
205
+ const precheck = change.precheckScript
206
+ ? null
207
+ : change.precheck === undefined
208
+ ? (current.precheck ?? null)
209
+ : change.precheck;
210
+ const precheckScript = change.precheck
211
+ ? null
212
+ : change.precheckScript === undefined
213
+ ? (current.precheckScript ?? null)
214
+ : change.precheckScript;
181
215
  const rows: Row[] = await this.#sql`
182
216
  UPDATE schedules SET
183
217
  title = ${change.title ?? current.title},
184
218
  prompt = ${change.prompt ?? current.prompt},
185
219
  recurrence = ${JSON.stringify(change.recurrence ?? current.recurrence)},
186
220
  next_run = ${change.nextRun ?? current.nextRun},
187
- precheck = ${change.precheck === undefined ? (current.precheck ?? null) : change.precheck}
221
+ precheck = ${precheck},
222
+ precheck_script = ${precheckScript}
188
223
  WHERE id = ${id}
189
224
  RETURNING *`;
190
225
  return rows[0] ? toSchedule(rows[0]) : undefined;
@@ -6,7 +6,12 @@ import type { ScheduleStore } from "../../services.ts";
6
6
  import type { ScheduleToolName } from "../../shared/schedule-tools.ts";
7
7
  import { type Tier, tierAtLeast } from "../../speakers.ts";
8
8
  import { timeZone, zonedStamp } from "../../time.ts";
9
- import type { PrecheckFinding, PrecheckRegistry } from "./prechecks.ts";
9
+ import {
10
+ checkPrecheckScript,
11
+ PRECHECK_SCRIPT_CHARS,
12
+ type PrecheckFinding,
13
+ type PrecheckRegistry,
14
+ } from "./prechecks.ts";
10
15
  import {
11
16
  describeRecurrence,
12
17
  nextRun,
@@ -18,6 +23,8 @@ import type { Schedule } from "./schedule-store.ts";
18
23
 
19
24
  const TITLE_CHARS = 80;
20
25
  const LIST_PROMPT_PREVIEW = 200;
26
+ /** How long schedule_list waits for the script runner to say what a script may call. */
27
+ const DESCRIBE_TIMEOUT_MS = 10_000;
21
28
  const DAY_MS = 86_400_000;
22
29
 
23
30
  export interface ScheduleToolContext {
@@ -31,8 +38,12 @@ export interface ScheduleToolContext {
31
38
  /** Who asked, recorded on created schedules; a scheduled run speaks for its creator. */
32
39
  author: { id: string; name: string; tier?: Tier };
33
40
  now: Date;
34
- /** The host's prechecks a schedule may name; without them, none can be attached. */
35
- prechecks?: Pick<PrecheckRegistry, "get" | "list">;
41
+ /**
42
+ * The host's prechecks a schedule may name, and the runner of the scripts it may carry instead;
43
+ * without them, none can be attached.
44
+ */
45
+ prechecks?: Pick<PrecheckRegistry, "get" | "list"> &
46
+ Partial<Pick<PrecheckRegistry, "scriptRunner">>;
36
47
  }
37
48
 
38
49
  /** The limits of the context's target; a target without them may not schedule. */
@@ -84,11 +95,57 @@ function precheckName(ctx: ScheduleToolContext, value: unknown): string {
84
95
  return name;
85
96
  }
86
97
 
87
- /** The prechecks a schedule may name, for schedule_list; empty when the host registers none. */
88
- function precheckCatalog(ctx: ScheduleToolContext): string {
98
+ /** A precheck script the host's sandbox runs; refused when the host has no runner for scripts. */
99
+ function precheckScript(ctx: ScheduleToolContext, value: unknown): string {
100
+ if (!ctx.prechecks?.scriptRunner?.()) {
101
+ const names = (ctx.prechecks?.list() ?? []).map((p) => p.name);
102
+ throw new ScheduleError(
103
+ `this host runs no precheck scripts; ${names.length ? `attach a registered precheck instead: ${names.join(", ")}` : "it registers no prechecks either"}`,
104
+ );
105
+ }
106
+ return checkPrecheckScript(value);
107
+ }
108
+
109
+ /** The prechecks a schedule may name or write, for schedule_list; empty when the host offers neither. */
110
+ async function precheckCatalog(ctx: ScheduleToolContext): Promise<string> {
89
111
  const all = ctx.prechecks?.list() ?? [];
90
- if (all.length === 0) return "";
91
- return `\n\nPrechecks you can attach with precheck on schedule_create or schedule_update; the host runs one before each turn and wakes you only when it finds something:\n${all.map((p) => `- ${p.name}: ${p.description.replace(/\n/g, " ")}`).join("\n")}`;
112
+ const runner = ctx.prechecks?.scriptRunner?.();
113
+ const named =
114
+ all.length === 0
115
+ ? ""
116
+ : `\n\nPrechecks you can attach with precheck on schedule_create or schedule_update; the host runs one before each turn and wakes you only when it finds something:\n${all.map((p) => `- ${p.name}: ${p.description.replace(/\n/g, " ")}`).join("\n")}`;
117
+ if (!runner) return named;
118
+ // The list still answers when the host cannot say, or is slow to say, what a script may reach here.
119
+ let timer: ReturnType<typeof setTimeout> | undefined;
120
+ const late = new Promise<string>((resolve) => {
121
+ timer = setTimeout(
122
+ () => resolve("it did not answer in time"),
123
+ DESCRIBE_TIMEOUT_MS,
124
+ );
125
+ });
126
+ const described = Promise.resolve()
127
+ .then(() =>
128
+ runner.describe({
129
+ channel: ctx.channel,
130
+ target: ctx.target.name,
131
+ ...(ctx.author.tier ? { tier: ctx.author.tier } : {}),
132
+ }),
133
+ )
134
+ .then(
135
+ (text) => ({ text }),
136
+ (error: unknown) => ({
137
+ error: error instanceof Error ? error.message : String(error),
138
+ }),
139
+ );
140
+ const answer = await Promise.race([
141
+ described,
142
+ late.then((error) => ({ error })),
143
+ ]).finally(() => clearTimeout(timer));
144
+ const guide =
145
+ "text" in answer
146
+ ? answer.text
147
+ : `The host could not say what a script may call here (${answer.error}); a script set now may fail when it runs.`;
148
+ return `${named}\n\nYou can write a precheck of your own instead, with precheck_script on schedule_create or schedule_update (null removes it): a JavaScript module, at most ${PRECHECK_SCRIPT_CHARS} characters, that the host runs in a sandbox before each turn. A schedule has a precheck or a precheck_script, not both.\n${guide}`;
92
149
  }
93
150
 
94
151
  function hasTiming(input: Input): boolean {
@@ -121,7 +178,11 @@ function line(schedule: Schedule): string {
121
178
  schedule.prompt.length > LIST_PROMPT_PREVIEW
122
179
  ? `${schedule.prompt.slice(0, LIST_PROMPT_PREVIEW)}…`
123
180
  : schedule.prompt;
124
- const precheck = schedule.precheck ? `; precheck ${schedule.precheck}` : "";
181
+ const precheck = schedule.precheck
182
+ ? `; precheck ${schedule.precheck}`
183
+ : schedule.precheckScript
184
+ ? `; precheck script (${schedule.precheckScript.length} characters)`
185
+ : "";
125
186
  const last = schedule.lastRun
126
187
  ? `; last run ${zonedStamp(schedule.lastRun)} (${schedule.lastStatus ?? "?"})`
127
188
  : "";
@@ -159,10 +220,16 @@ export async function callScheduleTool(
159
220
  const title = text(input, "title", TITLE_CHARS);
160
221
  const prompt = text(input, "prompt", limits.promptChars);
161
222
  const [recurrence, next] = timing(ctx, input);
162
- const precheck =
163
- input.precheck === undefined || input.precheck === null
164
- ? undefined
165
- : precheckName(ctx, input.precheck);
223
+ const given = (name: string) =>
224
+ input[name] !== undefined && input[name] !== null;
225
+ if (given("precheck") && given("precheck_script"))
226
+ throw new ScheduleError("give precheck or precheck_script, not both");
227
+ const precheck = given("precheck")
228
+ ? precheckName(ctx, input.precheck)
229
+ : undefined;
230
+ const script = given("precheck_script")
231
+ ? precheckScript(ctx, input.precheck_script)
232
+ : undefined;
166
233
  const existing = await ctx.store.forChannel(ctx.channel);
167
234
  if (existing.length >= limits.perChannel)
168
235
  throw new ScheduleError(
@@ -179,21 +246,29 @@ export async function callScheduleTool(
179
246
  createdByName: ctx.author.name,
180
247
  createdTier: ctx.author.tier ?? "owner",
181
248
  ...(precheck ? { precheck } : {}),
249
+ ...(script ? { precheckScript: script } : {}),
182
250
  });
183
- const checked = precheck ? `; precheck ${precheck} runs first` : "";
251
+ const checked = precheck
252
+ ? `; precheck ${precheck} runs first`
253
+ : script
254
+ ? "; its precheck script runs first"
255
+ : "";
184
256
  return `Scheduled #${created.id} "${title}": ${describeRecurrence(recurrence)}, first run ${zonedStamp(next)} ${messages().zoneTime(timeZone())}${checked}.`;
185
257
  }
186
258
  case "schedule_list": {
187
259
  if (input.id !== undefined) {
188
260
  const schedule = await own(ctx, input);
189
- return `${line(schedule).split("\n")[0]}\n\nPrompt:\n${schedule.prompt}`;
261
+ const script = schedule.precheckScript
262
+ ? `\n\nPrecheck script:\n${schedule.precheckScript}`
263
+ : "";
264
+ return `${line(schedule).split("\n")[0]}\n\nPrompt:\n${schedule.prompt}${script}`;
190
265
  }
191
266
  const all = await ctx.store.forChannel(ctx.channel);
192
267
  const listed =
193
268
  all.length === 0
194
269
  ? "This channel has no schedules."
195
270
  : `It is ${zonedStamp(ctx.now)} in ${messages().zoneName(timeZone())}.\n${all.map(line).join("\n")}`;
196
- return `${listed}${precheckCatalog(ctx)}`;
271
+ return `${listed}${await precheckCatalog(ctx)}`;
197
272
  }
198
273
  case "schedule_update": {
199
274
  const schedule = changeable(ctx, await own(ctx, input));
@@ -207,19 +282,35 @@ export async function callScheduleTool(
207
282
  change.recurrence = recurrence;
208
283
  change.nextRun = next;
209
284
  }
210
- // null removes the precheck; a name must be registered.
285
+ // null removes one; a name must be registered, and a script replaces a name and back.
286
+ if (
287
+ input.precheck !== undefined &&
288
+ input.precheck !== null &&
289
+ input.precheck_script !== undefined &&
290
+ input.precheck_script !== null
291
+ )
292
+ throw new ScheduleError("give precheck or precheck_script, not both");
211
293
  if (input.precheck !== undefined)
212
294
  change.precheck =
213
295
  input.precheck === null ? null : precheckName(ctx, input.precheck);
296
+ if (input.precheck_script !== undefined)
297
+ change.precheckScript =
298
+ input.precheck_script === null
299
+ ? null
300
+ : precheckScript(ctx, input.precheck_script);
301
+ if (change.precheck) change.precheckScript = null;
302
+ if (change.precheckScript) change.precheck = null;
214
303
  if (Object.keys(change).length === 0)
215
304
  throw new ScheduleError(
216
- "give a title, prompt, timing, or precheck to change",
305
+ "give a title, prompt, timing, precheck, or precheck_script to change",
217
306
  );
218
307
  const updated = await ctx.store.update(ctx.channel, schedule.id, change);
219
308
  if (!updated) throw new ScheduleError(`schedule #${schedule.id} is gone`);
220
309
  const checked = updated.precheck
221
310
  ? `; precheck ${updated.precheck} runs first`
222
- : "";
311
+ : updated.precheckScript
312
+ ? "; its precheck script runs first"
313
+ : "";
223
314
  return `Updated #${updated.id} "${updated.title}": ${describeRecurrence(updated.recurrence)}, next run ${zonedStamp(updated.nextRun)}${checked}.`;
224
315
  }
225
316
  case "schedule_cancel": {
@@ -1,11 +1,14 @@
1
1
  import type { ScheduledOutcome } from "../../contract/channels.ts";
2
2
  import type { Logger } from "../../log.ts";
3
3
  import type { ScheduleStore } from "../../services.ts";
4
+ import { timeZone, zonedStamp } from "../../time.ts";
4
5
  import {
6
+ type Precheck,
5
7
  type PrecheckFinding,
6
8
  type PrecheckOutcome,
7
9
  type PrecheckRegistry,
8
10
  runPrecheck,
11
+ SCRIPT_PRECHECK,
9
12
  } from "./prechecks.ts";
10
13
  import { nextRun } from "./recurrence.ts";
11
14
  import type { Schedule } from "./schedule-store.ts";
@@ -21,14 +24,18 @@ export interface ScheduledRunner {
21
24
  ): Promise<ScheduledOutcome>;
22
25
  }
23
26
 
27
+ /** How long stop waits for running prechecks to clean up after aborting them. */
28
+ export const PRECHECK_STOP_MS = 15_000;
29
+
24
30
  /** A run found this late, for example after the service was down, is skipped instead of run. */
25
31
  export const LATE_LIMIT_MS = 12 * 3_600_000;
26
32
 
27
33
  export interface SchedulerOptions {
28
34
  store: Pick<ScheduleStore, "due" | "claim" | "recordStatus">;
29
35
  runner: ScheduledRunner;
30
- /** The host's prechecks; without them, a schedule that names one runs with that as its error. */
31
- prechecks?: Pick<PrecheckRegistry, "get">;
36
+ /** The host's prechecks and script runner; without them, a schedule that has one runs with that as its error. */
37
+ prechecks?: Pick<PrecheckRegistry, "get"> &
38
+ Partial<Pick<PrecheckRegistry, "scriptRunner">>;
32
39
  /** Posts a skipping precheck's note in the schedule's channel as the bot's own message, which starts no turn. */
33
40
  notify?: (schedule: Schedule, note: string) => Promise<void>;
34
41
  logger: Logger;
@@ -67,6 +74,9 @@ function precheckPrefix(finding: PrecheckFinding | undefined): string {
67
74
  export class Scheduler {
68
75
  readonly #options: SchedulerOptions;
69
76
  readonly #running = new Set<Promise<void>>();
77
+ /** Prechecks in flight, including their cleanup after a timeout, which stop waits for. */
78
+ readonly #prechecks = new Set<Promise<unknown>>();
79
+ readonly #stopping = new AbortController();
70
80
  #timer: ReturnType<typeof setInterval> | undefined;
71
81
  #ticking = false;
72
82
 
@@ -82,13 +92,33 @@ export class Scheduler {
82
92
  void this.tick();
83
93
  }
84
94
 
85
- stop(): void {
95
+ /**
96
+ * Stops checking, aborts running prechecks, and waits up to PRECHECK_STOP_MS for them to clean
97
+ * up, so a sandboxed script never outlives the host. A turn already running is left to the
98
+ * host's drain; no precheck that ends now starts one.
99
+ */
100
+ async stop(): Promise<void> {
86
101
  clearInterval(this.#timer);
102
+ this.#stopping.abort();
103
+ let timer: ReturnType<typeof setTimeout> | undefined;
104
+ const limit = new Promise<"late">((resolve) => {
105
+ timer = setTimeout(() => resolve("late"), PRECHECK_STOP_MS);
106
+ });
107
+ const settled = Promise.allSettled([...this.#prechecks]);
108
+ try {
109
+ if ((await Promise.race([settled, limit])) === "late")
110
+ this.#options.logger.warn(
111
+ { prechecks: this.#prechecks.size },
112
+ "prechecks still cleaning up when the scheduler stopped",
113
+ );
114
+ } finally {
115
+ clearTimeout(timer);
116
+ }
87
117
  }
88
118
 
89
119
  /** Claims every due schedule and starts its run; runs continue after tick resolves. */
90
120
  async tick(): Promise<void> {
91
- if (this.#ticking) return;
121
+ if (this.#ticking || this.#stopping.signal.aborted) return;
92
122
  this.#ticking = true;
93
123
  const { store, logger } = this.#options;
94
124
  try {
@@ -132,20 +162,26 @@ export class Scheduler {
132
162
  async #fire(schedule: Schedule, firedAt: Date): Promise<void> {
133
163
  const { runner, logger } = this.#options;
134
164
  let finding: PrecheckFinding | undefined;
135
- if (schedule.precheck) {
136
- const decision = await this.#precheck(
137
- schedule,
138
- schedule.precheck,
139
- firedAt,
140
- );
165
+ const name =
166
+ schedule.precheck ??
167
+ (schedule.precheckScript ? SCRIPT_PRECHECK : undefined);
168
+ if (name) {
169
+ const decision = await this.#precheck(schedule, name, firedAt);
170
+ if (this.#stopping.signal.aborted) {
171
+ await this.#record(
172
+ schedule,
173
+ "skipped: the host stopped during its precheck",
174
+ );
175
+ return;
176
+ }
141
177
  if (decision.kind === "skip") {
142
178
  await this.#skip(schedule, decision.note);
143
179
  return;
144
180
  }
145
181
  finding =
146
182
  decision.kind === "wake"
147
- ? { precheck: schedule.precheck, context: decision.context }
148
- : { precheck: schedule.precheck, error: decision.error };
183
+ ? { precheck: name, context: decision.context }
184
+ : { precheck: name, error: decision.error };
149
185
  }
150
186
  logger.info(
151
187
  {
@@ -163,19 +199,63 @@ export class Scheduler {
163
199
  );
164
200
  }
165
201
 
202
+ /**
203
+ * The schedule's precheck: a registered one by name, or its script through the host's runner.
204
+ * A script never runs in this process; without a runner it fails, and the turn still runs.
205
+ */
206
+ #resolve(schedule: Schedule, firedAt: Date): Precheck | string {
207
+ const { prechecks } = this.#options;
208
+ if (schedule.precheck)
209
+ return (
210
+ prechecks?.get(schedule.precheck) ??
211
+ "no precheck of that name is registered"
212
+ );
213
+ const script = schedule.precheckScript ?? "";
214
+ const runner = prechecks?.scriptRunner?.();
215
+ if (!runner)
216
+ return "this host has no precheck script runner, so the script did not run";
217
+ const zone = timeZone();
218
+ return {
219
+ name: SCRIPT_PRECHECK,
220
+ description: "the schedule's own precheck script",
221
+ ...(runner.timeoutMs === undefined
222
+ ? {}
223
+ : { timeoutMs: runner.timeoutMs }),
224
+ run: (context) =>
225
+ runner.run(script, {
226
+ ...context,
227
+ timeZone: zone,
228
+ today: zonedStamp(firedAt).slice(0, 10),
229
+ }),
230
+ };
231
+ }
232
+
166
233
  /** Runs the schedule's precheck; a missing one fails like a throw, so the turn still runs. */
167
234
  async #precheck(
168
235
  schedule: Schedule,
169
236
  name: string,
170
237
  firedAt: Date,
171
238
  ): Promise<PrecheckOutcome> {
172
- const { prechecks, logger } = this.#options;
239
+ const { logger } = this.#options;
173
240
  let decision: PrecheckOutcome;
174
241
  try {
175
- const precheck = prechecks?.get(name);
176
- decision = precheck
177
- ? await runPrecheck(precheck, { schedule, firedAt })
178
- : { kind: "failed", error: "no precheck of that name is registered" };
242
+ const precheck = this.#resolve(schedule, firedAt);
243
+ decision =
244
+ typeof precheck === "string"
245
+ ? { kind: "failed", error: precheck }
246
+ : await runPrecheck(
247
+ precheck,
248
+ { schedule, firedAt },
249
+ {
250
+ signal: this.#stopping.signal,
251
+ settled: (run) => {
252
+ const tracked = run
253
+ .catch(() => undefined)
254
+ .finally(() => this.#prechecks.delete(tracked));
255
+ this.#prechecks.add(tracked);
256
+ },
257
+ },
258
+ );
179
259
  } catch (error) {
180
260
  decision = {
181
261
  kind: "failed",
@@ -27,8 +27,9 @@ export interface OwnerSchedules {
27
27
  * messages for a conversation no chat surface carries, where a run could not be posted.
28
28
  */
29
29
  channelFor: (channel: ChannelKey) => Promise<ChannelKey>;
30
- /** The host's prechecks a schedule may name; without them, none can be attached. */
31
- prechecks?: Pick<PrecheckRegistry, "get" | "list">;
30
+ /** The host's prechecks a schedule may name, and the runner of scripts it may carry; without them, none can be attached. */
31
+ prechecks?: Pick<PrecheckRegistry, "get" | "list"> &
32
+ Partial<Pick<PrecheckRegistry, "scriptRunner">>;
32
33
  }
33
34
 
34
35
  /** Finds another agent's channel, for reading its schedules; throws ScheduleError when there is none. */
@@ -65,6 +66,7 @@ export function schedulesExtension(
65
66
  const defs = scheduleToolSpecs({
66
67
  locale: activeLocale(),
67
68
  timeZone: timeZone(),
69
+ precheckScripts: prechecks?.scriptRunner?.() !== undefined,
68
70
  }).map((base) => {
69
71
  const spec = agents ? withAgentOption(base) : base;
70
72
  return {
@@ -74,12 +74,33 @@ export interface ScheduleToolWording {
74
74
  locale: Locale;
75
75
  /** An IANA time zone, such as `Asia/Taipei`. */
76
76
  timeZone: string;
77
+ /**
78
+ * Whether the host runs agents' precheck scripts, so schedule_create and schedule_update take
79
+ * `precheck_script`; without it they neither take nor mention one.
80
+ */
81
+ precheckScripts?: boolean;
77
82
  }
78
83
 
79
84
  export function scheduleToolSpecs(
80
85
  wording: ScheduleToolWording,
81
86
  ): readonly ScheduleToolSpec[] {
82
87
  const zone = catalogFor(wording.locale).zoneTime(wording.timeZone);
88
+ const script = (cleared: boolean): Record<string, TSchema> =>
89
+ wording.precheckScripts
90
+ ? {
91
+ precheck_script: Type.Optional(
92
+ cleared
93
+ ? Type.Union([Type.String(), Type.Null()], {
94
+ description:
95
+ "A JavaScript module the host runs in a sandbox before each turn, as schedule_list explains; it replaces precheck. null removes it.",
96
+ })
97
+ : Type.String({
98
+ description:
99
+ "A JavaScript module the host runs in a sandbox before each turn, as schedule_list explains; instead of precheck.",
100
+ }),
101
+ ),
102
+ }
103
+ : {};
83
104
  return [
84
105
  {
85
106
  name: "schedule_create",
@@ -97,6 +118,7 @@ export function scheduleToolSpecs(
97
118
  "The name of a precheck the host runs first, from schedule_list; you are woken only when it finds something.",
98
119
  }),
99
120
  ),
121
+ ...script(false),
100
122
  }),
101
123
  },
102
124
  {
@@ -125,6 +147,7 @@ export function scheduleToolSpecs(
125
147
  "A precheck's name from schedule_list to run first, or null to remove the schedule's precheck.",
126
148
  }),
127
149
  ),
150
+ ...script(true),
128
151
  }),
129
152
  },
130
153
  {
@@ -4,6 +4,9 @@ import {
4
4
  type PrecheckContext,
5
5
  type PrecheckRegistry,
6
6
  type PrecheckResult,
7
+ type PrecheckScope,
8
+ type PrecheckScriptContext,
9
+ type PrecheckScriptRunner,
7
10
  } from "../modules/schedules/prechecks.ts";
8
11
 
9
12
  /** What a fake precheck answers: a result, an Error it throws, or a function of its context. */
@@ -43,6 +46,47 @@ export function fakePrecheck(
43
46
  };
44
47
  }
45
48
 
49
+ /** What a fake script runner answers: a result, an Error it throws, or a function of the script and its context. */
50
+ export type FakeScriptAnswer =
51
+ | PrecheckResult
52
+ | Error
53
+ | ((
54
+ script: string,
55
+ context: PrecheckScriptContext,
56
+ ) => PrecheckResult | Promise<PrecheckResult>);
57
+
58
+ /** A precheck script runner for tests, which records every script it was asked to run. */
59
+ export interface FakeScriptRunner extends PrecheckScriptRunner {
60
+ readonly calls: { script: string; context: PrecheckScriptContext }[];
61
+ }
62
+
63
+ /**
64
+ * A script runner that runs nothing: it answers as the test says, for
65
+ * `registry.useScriptRunner(fakeScriptRunner({ wake: false }))`. `describe` defaults to a line
66
+ * naming it a test runner.
67
+ */
68
+ export function fakeScriptRunner(
69
+ answer: FakeScriptAnswer,
70
+ options: {
71
+ describe?: (scope: PrecheckScope) => string;
72
+ timeoutMs?: number;
73
+ } = {},
74
+ ): FakeScriptRunner {
75
+ const calls: FakeScriptRunner["calls"] = [];
76
+ return {
77
+ calls,
78
+ ...(options.timeoutMs === undefined
79
+ ? {}
80
+ : { timeoutMs: options.timeoutMs }),
81
+ describe: options.describe ?? (() => "A test script runner."),
82
+ run: async (script, context) => {
83
+ calls.push({ script, context });
84
+ if (answer instanceof Error) throw answer;
85
+ return typeof answer === "function" ? answer(script, context) : answer;
86
+ },
87
+ };
88
+ }
89
+
46
90
  /**
47
91
  * A real in-memory precheck registry with the given prechecks registered, for a test that builds
48
92
  * a scheduler or schedule tools itself. It refuses what the host's refuses.
package/src/index.ts CHANGED
@@ -132,8 +132,14 @@ export type {
132
132
  PrecheckFinding,
133
133
  PrecheckRegistry,
134
134
  PrecheckResult,
135
+ PrecheckScope,
136
+ PrecheckScriptContext,
137
+ PrecheckScriptRunner,
138
+ } from "./core/modules/schedules/prechecks.ts";
139
+ export {
140
+ PRECHECK_SCRIPT_CHARS,
141
+ PRECHECK_TIMEOUT_MS,
135
142
  } from "./core/modules/schedules/prechecks.ts";
136
- export { PRECHECK_TIMEOUT_MS } from "./core/modules/schedules/prechecks.ts";
137
143
  export type {
138
144
  Recurrence,
139
145
  Weekday,
package/src/testing.ts CHANGED
@@ -86,8 +86,14 @@ export { partial } from "./core/testing/partial.ts";
86
86
  export type {
87
87
  FakePrecheck,
88
88
  FakePrecheckAnswer,
89
+ FakeScriptAnswer,
90
+ FakeScriptRunner,
91
+ } from "./core/testing/prechecks.ts";
92
+ export {
93
+ fakePrecheck,
94
+ fakePrechecks,
95
+ fakeScriptRunner,
89
96
  } from "./core/testing/prechecks.ts";
90
- export { fakePrecheck, fakePrechecks } from "./core/testing/prechecks.ts";
91
97
  export type {
92
98
  RecordedLog,
93
99
  RecordingLogger,