pi-roundtable 0.7.12 → 0.7.14

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,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.7.14] - 2026-10-03
9
+
10
+ ### Fixed
11
+
12
+ - pi-roundtable-sandbox: a model request the broker refuses is a 400, worded so Claude Code resends it without its mid-conversation system messages, instead of a 502 it retried for minutes; see its changelog.
13
+
14
+ ## [0.7.13] - 2026-10-03
15
+
16
+ ### Changed
17
+
18
+ - Behavior change: a precheck script's MCP calls follow the hold rules. Saving a script reads its `mcp.call` and `mcp.json` calls (server and tool written as strings, `mcp` used for nothing else, or the script is refused) and judges each with the host's hold rules; if any is held, the `schedule_create` or `schedule_update` call is itself held for the owner through the confirmation gate, once, and the scheduled runs do not ask again. The schedule keeps the tools its script may call (`Schedule.precheckTools`, `NewSchedule.precheckTools`, `ScheduleChange.precheckTools`, the type `PrecheckTool`) in a nullable `precheck_tools` column (the new `schedules-precheck-tools` migration); the runner gets them as `PrecheckScriptContext.tools` and must refuse every other call; `schedule_list` shows them, marking those the owner approved. A script saved before has no recorded tools: its first run reads them, runs it if none is held, and otherwise wakes the agent to save it again.
19
+ - Behavior change: `PrecheckScriptRunner` needs `toolName(server, tool)`, the name the hold rules know a script's call by.
20
+ - `HoldRule` takes an optional `mayHold(tool)`, for a rule whose verdict depends on the input, and an optional `approvalTier(tool, input, context)`, the lowest tier that may approve a call it holds when higher than the tool's own; `HoldCheck` (and `holdChain`'s) gains `mayHold` and `approvalTier`. A held call keeps that tier (`HeldCall.minTier`), and both its approval card and a confirming message require it, so saving a script whose runs send mail needs whoever may approve sending mail, not only whoever may schedule. `ScheduleToolContext` takes `holds`.
21
+
8
22
  ## [0.7.12] - 2026-10-03
9
23
 
10
24
  ### Fixed
package/docs/plugins.md CHANGED
@@ -501,6 +501,10 @@ export const cleanup = definePlugin({
501
501
  ```
502
502
  <!-- /example -->
503
503
 
504
+ A rule whose verdict depends on the input, such as one that holds only a `delete` action, can also answer `mayHold(tool)`: whether it may hold some call of that tool.
505
+ It is asked when the input is not known yet, as for a [precheck script](#precheck-scripts-prechecks-the-agent-writes)'s call whose arguments are computed when it runs; a rule without it is judged by `describe` with an empty input.
506
+ A rule whose held call stands for others can answer `approvalTier(tool, input, context)`: the lowest tier that may approve it when higher than the tool's own. The held call keeps it as `minTier`, and both its card and a confirming message require it.
507
+
504
508
  ### `prompt`: text added to every agent turn
505
509
 
506
510
  Each section's `build` gets the agent, the speaker (undefined between turns), and the turn's scope.
@@ -812,6 +816,13 @@ pi-roundtable-sandbox's `precheckScriptRunner` runs each script in a sealed cont
812
816
  - 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
817
  - 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
818
 
819
+ A script's MCP calls follow the hold rules as the agent's own calls do.
820
+ When a script is saved, the core reads its `mcp.call(server, tool, args)` and `mcp.json(...)` calls; server and tool must be written as strings, and `mcp` may be used for nothing else.
821
+ The runner's `toolName(server, tool)` gives the name the hold rules know each tool by, and each call is judged by its arguments when they are written out, or otherwise by `describe` with an empty input and the rules' `mayHold`.
822
+ If any call is held, saving the script is itself a held action, approved or refused through the confirmation gate like any other, so it is approved once, by someone who may approve each of those calls, and the scheduled runs do not ask again.
823
+ The schedule keeps the tools it may call as `precheckTools` (`PrecheckTool`, its held ones marked), the runner gets them as the context's `tools` and must refuse every other call, and `schedule_list` shows them.
824
+ A script saved before 0.7.13 has no recorded tools: its first run reads them, runs it if none is held, and otherwise wakes the agent to save it again for approval.
825
+
815
826
 
816
827
  ### `migrations` and `context.database()`: tables of your own
817
828
 
@@ -2524,6 +2535,7 @@ Import from the entries listed below; source area files are internal.
2524
2535
  | `PrecheckScope` | `pi-roundtable` | type |
2525
2536
  | `PrecheckScriptContext` | `pi-roundtable` | type |
2526
2537
  | `PrecheckScriptRunner` | `pi-roundtable` | type |
2538
+ | `PrecheckTool` | `pi-roundtable` | type |
2527
2539
  | `PRECHECK_SCRIPT_CHARS` | `pi-roundtable` | value |
2528
2540
  | `SKILLS` | `pi-roundtable` | value |
2529
2541
  | `Schedule` | `pi-roundtable` | type |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.7.12",
3
+ "version": "0.7.14",
4
4
  "description": "A plugin-driven Pi agent server for Discord",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -56,6 +56,7 @@
56
56
  },
57
57
  "dependencies": {
58
58
  "@babel/parser": "7.29.9",
59
+ "@babel/types": "7.29.8",
59
60
  "@earendil-works/pi-ai": ">=0.99.2 <2",
60
61
  "@earendil-works/pi-coding-agent": ">=0.99.2 <2",
61
62
  "canvas": "3.2.3",
@@ -12,7 +12,7 @@ import { splitReply } from "../presentation/reply-splitter.ts";
12
12
  import { thinkingLine } from "../presentation/thinking-line.ts";
13
13
  import { withReplyFiles } from "../reply-files.ts";
14
14
  import { endOf, settleTurn } from "../routing/settle-turn.ts";
15
- import type { Speaker } from "../speakers.ts";
15
+ import { type Speaker, tierAtLeast } from "../speakers.ts";
16
16
  import { toolTiers } from "../tool-tiers.ts";
17
17
  import { AgentMessages } from "./agent-messages.ts";
18
18
  import type { Agent } from "./agent-store.ts";
@@ -145,7 +145,11 @@ export class TeamTurns {
145
145
  /** Whether the speaker's tier holds every tool of the held actions. */
146
146
  #mayApprove(speaker: Speaker, pending: PendingConfirmation): boolean {
147
147
  const tiers = this.#options.toolTiers ?? toolTiers();
148
- return pending.calls.every((call) => tiers.allows(speaker.tier, call.tool));
148
+ return pending.calls.every(
149
+ (call) =>
150
+ tiers.allows(speaker.tier, call.tool) &&
151
+ (!call.minTier || tierAtLeast(speaker.tier, call.minTier)),
152
+ );
149
153
  }
150
154
 
151
155
  #agentOf(channel: ChannelKey): Agent {
@@ -14,6 +14,7 @@ import {
14
14
  } from "../modules/delegation/delegator.ts";
15
15
  import { WebResearchWorker } from "../modules/delegation/web-research-worker.ts";
16
16
  import { notifyExtension } from "../modules/notify/notify.ts";
17
+ import { precheckScriptHoldRule } from "../modules/schedules/precheck-tools.ts";
17
18
  import { Scheduler } from "../modules/schedules/scheduler.ts";
18
19
  import { schedulesExtension } from "../modules/schedules/schedules.ts";
19
20
  import type { RoundtablePlugin } from "../plugin.ts";
@@ -77,7 +78,14 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
77
78
  return {
78
79
  name: "modules",
79
80
  provides: [BACKGROUND_TURNS, DELEGATION],
80
- setup: ({ conversations, services, logger, surfaces }) => {
81
+ setup: ({
82
+ conversations,
83
+ services,
84
+ logger,
85
+ surfaces,
86
+ sessions,
87
+ toolTiers,
88
+ }) => {
81
89
  const schedules = services.get(SCHEDULES);
82
90
  // Absent when a plugin list leaves the prechecks plugin out: then none can be attached.
83
91
  const prechecks = services.find(PRECHECKS);
@@ -117,6 +125,14 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
117
125
  services: [
118
126
  { name: "delegator", busy: () => delegator.runningChannels() },
119
127
  ],
128
+ // Saving a precheck script that calls a held tool waits for the owner, as the call would.
129
+ holdRules: [
130
+ precheckScriptHoldRule({
131
+ prechecks: () => prechecks,
132
+ holds: () => sessions().holds,
133
+ tiers: () => toolTiers,
134
+ }),
135
+ ],
120
136
  sessionTools: [
121
137
  fixed("notify", () => notifyExtension(connection, owner)),
122
138
  fixed("schedules", (session) =>
@@ -126,6 +142,7 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
126
142
  owner: { id: owner.id, name: owner.name },
127
143
  channelFor: ownerChannelFor,
128
144
  ...(prechecks ? { prechecks } : {}),
145
+ holds: () => sessions().holds,
129
146
  },
130
147
  session.homeChannel,
131
148
  served(session),
@@ -160,13 +177,14 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
160
177
  export function schedulerPlugin(): RoundtablePlugin {
161
178
  return {
162
179
  name: "schedules",
163
- setup: ({ conversations, services, env, logger, surfaces }) => {
180
+ setup: ({ conversations, services, env, logger, surfaces, sessions }) => {
164
181
  const schedules = services.get(SCHEDULES);
165
182
  const prechecks = services.find(PRECHECKS);
166
183
  const scheduler = new Scheduler({
167
184
  store: schedules,
168
185
  runner: services.get(BACKGROUND_TURNS),
169
186
  ...(prechecks ? { prechecks } : {}),
187
+ holds: () => sessions().holds,
170
188
  // The bot's own message: the surface drops it, so it starts no turn.
171
189
  notify: (schedule, note) =>
172
190
  surfaces.sendReply(schedule.channel, {
@@ -1,5 +1,6 @@
1
1
  import type { InboundMessage } from "../contract/channels.ts";
2
2
  import type { ChannelKey } from "../sessions.ts";
3
+ import type { Tier } from "../speakers.ts";
3
4
 
4
5
  export { QUEUED_MARK, STEERED_MARK } from "../contract/channels.ts";
5
6
  export type { ChannelKey, InboundMessage };
@@ -16,6 +17,8 @@ export interface HeldCall {
16
17
  input: string;
17
18
  /** What the call would do, in plain words. */
18
19
  action: string;
20
+ /** The lowest tier that may approve it, when higher than its tool's own; see `HoldRule.approvalTier`. */
21
+ minTier?: Tier;
19
22
  }
20
23
 
21
24
  export interface PendingConfirmation {
package/src/core/holds.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { PluginError } from "./errors.ts";
2
+ import { type Tier, tierAtLeast } from "./speakers.ts";
2
3
 
3
4
  /** What a hold rule knows about the session making the call. */
4
5
  export interface HoldContext {
@@ -17,14 +18,49 @@ export interface HoldRule {
17
18
  input: Record<string, unknown>,
18
19
  context: HoldContext,
19
20
  ): string | undefined;
21
+ /**
22
+ * Whether this rule may hold some call of `tool`, for a rule whose verdict depends on the input
23
+ * (such as an action argument). Asked when the input is not known yet, as for a precheck
24
+ * script's call whose arguments are computed when it runs; a rule without it is judged by
25
+ * `describe` with an empty input.
26
+ */
27
+ mayHold?(tool: string): boolean;
28
+ /**
29
+ * The lowest tier that may approve a call this rule holds, when it is higher than the tool's own:
30
+ * for a call that stands for others, such as saving a script whose runs make held calls.
31
+ */
32
+ approvalTier?(
33
+ tool: string,
34
+ input: Record<string, unknown>,
35
+ context: HoldContext,
36
+ ): Tier | undefined;
20
37
  }
21
38
 
22
39
  /** The description of a call that must be approved first, or undefined to let it run. */
23
- export type HoldCheck = (
40
+ export type HoldCheck = ((
24
41
  tool: string,
25
42
  input: Record<string, unknown>,
26
43
  context: HoldContext,
27
- ) => string | undefined;
44
+ ) => string | undefined) & {
45
+ /** The name of a rule that may hold some call of `tool` whatever its input; see `HoldRule.mayHold`. */
46
+ mayHold?(tool: string): string | undefined;
47
+ /** The highest tier any rule requires to approve the call; see `HoldRule.approvalTier`. */
48
+ approvalTier?(
49
+ tool: string,
50
+ input: Record<string, unknown>,
51
+ context: HoldContext,
52
+ ): Tier | undefined;
53
+ };
54
+
55
+ /** The higher of two tiers; undefined only when both are. */
56
+ export function higherTier(
57
+ a: Tier | undefined,
58
+ b: Tier | undefined,
59
+ ): Tier | undefined {
60
+ if (!a) return b;
61
+ if (!b) return a;
62
+ return tierAtLeast(a, b) ? a : b;
63
+ }
28
64
 
29
65
  /** Asks the rules in order; the first description holds the call. */
30
66
  export function holdChain(rules: readonly HoldRule[]): HoldCheck {
@@ -36,11 +72,19 @@ export function holdChain(rules: readonly HoldRule[]): HoldCheck {
36
72
  );
37
73
  names.add(rule.name);
38
74
  }
39
- return (tool, input, context) => {
75
+ const check: HoldCheck = (tool, input, context) => {
40
76
  for (const rule of rules) {
41
77
  const description = rule.describe(tool, input, context);
42
78
  if (description !== undefined) return description;
43
79
  }
44
80
  return undefined;
45
81
  };
82
+ check.mayHold = (tool) => rules.find((rule) => rule.mayHold?.(tool))?.name;
83
+ check.approvalTier = (tool, input, context) =>
84
+ rules.reduce<Tier | undefined>(
85
+ (tier, rule) =>
86
+ higherTier(tier, rule.approvalTier?.(tool, input, context)),
87
+ undefined,
88
+ );
89
+ return check;
46
90
  }
@@ -53,6 +53,13 @@ export function schedulesEn(ctx: CatalogContext) {
53
53
  `Checked first by its own precheck script (${chars} characters)`,
54
54
  schedulePrecheckNote: (id: number, title: string, note: string) =>
55
55
  `-# Schedule #${id} ${title}, skipped by its precheck: ${note.replace(/\n+/g, "\n-# ")}`,
56
+ precheckScriptHeld: (
57
+ schedule: { title?: string; id?: number },
58
+ held: readonly string[],
59
+ ) =>
60
+ `save schedule ${schedule.title ? `"${schedule.title}" ` : schedule.id === undefined ? "" : `#${schedule.id} `}with a precheck script that, each time it runs, may: ${held.join("; ")}`,
61
+ precheckToolMayHold: (tool: string, rule: string) =>
62
+ `call ${tool}, which the ${rule} rule may hold depending on its input`,
56
63
  scheduleChoice: (id: number, title: string, recurrence: string) =>
57
64
  `#${id} ${title} (${recurrence})`,
58
65
  scheduleTitle: "Schedules",
@@ -97,6 +104,13 @@ export function schedulesZhTW(
97
104
  `先由自訂的預檢腳本判斷(${chars} 字元)`,
98
105
  schedulePrecheckNote: (id: number, title: string, note: string) =>
99
106
  `-# 排程 #${id} ${title} 經預檢略過:${note.replace(/\n+/g, "\n-# ")}`,
107
+ precheckScriptHeld: (
108
+ schedule: { title?: string; id?: number },
109
+ held: readonly string[],
110
+ ) =>
111
+ `儲存排程${schedule.title ? `「${schedule.title}」` : schedule.id === undefined ? "" : ` #${schedule.id} `},它的預檢腳本每次執行都可能:${held.join(";")}`,
112
+ precheckToolMayHold: (tool: string, rule: string) =>
113
+ `呼叫 ${tool}(規則 ${rule} 會依輸入決定是否需要確認)`,
100
114
  scheduleChoice: (id: number, title: string, recurrence: string) =>
101
115
  `#${id} ${title}(${recurrence})`,
102
116
  scheduleTitle: "排程",
@@ -0,0 +1,387 @@
1
+ import { parse } from "@babel/parser";
2
+ import type { Node, Program } from "@babel/types";
3
+ import { ScheduleError } from "../../domain/errors.ts";
4
+ import {
5
+ type HoldCheck,
6
+ type HoldContext,
7
+ type HoldRule,
8
+ higherTier,
9
+ } from "../../holds.ts";
10
+ import { messages } from "../../i18n/index.ts";
11
+ import type { Tier } from "../../speakers.ts";
12
+ import type { ToolTiers } from "../../tool-tiers.ts";
13
+ import type { PrecheckRegistry } from "./prechecks.ts";
14
+
15
+ /**
16
+ * An MCP tool a schedule's precheck script may call, as approved when the script was saved. The
17
+ * runner lets the script call nothing else.
18
+ */
19
+ export interface PrecheckTool {
20
+ server: string;
21
+ tool: string;
22
+ /** What the host's hold rules said a call of it would do; set, the owner approved it on saving the script. */
23
+ held?: string;
24
+ }
25
+
26
+ /** One `mcp.call` or `mcp.json` in a script, with its arguments when they are written out literally. */
27
+ export interface PrecheckScriptCall {
28
+ server: string;
29
+ tool: string;
30
+ /** Undefined when the script computes them, so they are known only when it runs. */
31
+ args?: Record<string, unknown>;
32
+ }
33
+
34
+ const NOT_LITERAL = Symbol("not literal");
35
+
36
+ /** A value written out in the script itself. */
37
+ type Literal =
38
+ | string
39
+ | number
40
+ | boolean
41
+ | null
42
+ | Literal[]
43
+ | { [key: string]: Literal };
44
+
45
+ /** The value of an expression written out as JSON-like literals, or NOT_LITERAL. */
46
+ function literal(node: Node | null | undefined): Literal | typeof NOT_LITERAL {
47
+ if (!node) return NOT_LITERAL;
48
+ switch (node.type) {
49
+ case "StringLiteral":
50
+ case "NumericLiteral":
51
+ case "BooleanLiteral":
52
+ return node.value;
53
+ case "NullLiteral":
54
+ return null;
55
+ case "TemplateLiteral":
56
+ return node.expressions.length === 0
57
+ ? node.quasis.map((q) => q.value.cooked ?? q.value.raw).join("")
58
+ : NOT_LITERAL;
59
+ case "UnaryExpression":
60
+ return node.operator === "-" && node.argument.type === "NumericLiteral"
61
+ ? -node.argument.value
62
+ : NOT_LITERAL;
63
+ case "ArrayExpression": {
64
+ const items: Literal[] = [];
65
+ for (const item of node.elements) {
66
+ const value =
67
+ item && item.type !== "SpreadElement" ? literal(item) : NOT_LITERAL;
68
+ if (value === NOT_LITERAL) return NOT_LITERAL;
69
+ items.push(value);
70
+ }
71
+ return items;
72
+ }
73
+ case "ObjectExpression": {
74
+ const entries: [string, Literal][] = [];
75
+ for (const property of node.properties) {
76
+ if (property.type !== "ObjectProperty" || property.computed)
77
+ return NOT_LITERAL;
78
+ const key =
79
+ property.key.type === "Identifier"
80
+ ? property.key.name
81
+ : property.key.type === "StringLiteral"
82
+ ? property.key.value
83
+ : undefined;
84
+ const value = literal(property.value);
85
+ if (key === undefined || value === NOT_LITERAL) return NOT_LITERAL;
86
+ entries.push([key, value]);
87
+ }
88
+ return Object.fromEntries(entries);
89
+ }
90
+ default:
91
+ return NOT_LITERAL;
92
+ }
93
+ }
94
+
95
+ const REACH =
96
+ "a precheck script may use mcp only as mcp.call(server, tool, args) or mcp.json(server, tool, args), with server and tool written as strings, after taking it from the context as ({ mcp }) or const { mcp } = context";
97
+
98
+ function isNode(value: unknown): value is Node {
99
+ return (
100
+ typeof value === "object" &&
101
+ value !== null &&
102
+ typeof (value as { type?: unknown }).type === "string"
103
+ );
104
+ }
105
+
106
+ /** Every node under `node`, with the key it sits under in its parent. */
107
+ function* walk(
108
+ node: Node,
109
+ parent?: Node,
110
+ key?: string,
111
+ ): Generator<[Node, Node | undefined, string | undefined]> {
112
+ yield [node, parent, key];
113
+ for (const [childKey, value] of Object.entries(node)) {
114
+ if (
115
+ childKey === "loc" ||
116
+ childKey === "leadingComments" ||
117
+ childKey === "trailingComments" ||
118
+ childKey === "innerComments"
119
+ )
120
+ continue;
121
+ if (Array.isArray(value)) {
122
+ for (const item of value)
123
+ if (isNode(item)) yield* walk(item, node, childKey);
124
+ } else if (isNode(value)) yield* walk(value, node, childKey);
125
+ }
126
+ }
127
+
128
+ /** The longest precheck script a schedule may carry, in characters. */
129
+ export const PRECHECK_SCRIPT_CHARS = 8_000;
130
+
131
+ /**
132
+ * Reads a precheck script without running it: a JavaScript module within the size limit whose
133
+ * default export is what the runner calls, and the MCP calls it makes. Server and tool must be
134
+ * written as strings, and `mcp` may be used only for those calls, so the calls are known before it
135
+ * runs; anything else throws ScheduleError with what to fix. The runner still lets the script call
136
+ * only the tools approved for it.
137
+ */
138
+ export function readPrecheckScript(script: unknown): {
139
+ script: string;
140
+ calls: PrecheckScriptCall[];
141
+ } {
142
+ if (typeof script !== "string" || !script.trim())
143
+ throw new ScheduleError(
144
+ "precheck_script must be a JavaScript module with a default export",
145
+ );
146
+ if (script.length > PRECHECK_SCRIPT_CHARS)
147
+ throw new ScheduleError(
148
+ `precheck_script is ${script.length} characters; keep it within ${PRECHECK_SCRIPT_CHARS}`,
149
+ );
150
+ let program: Program;
151
+ try {
152
+ program = parse(script, {
153
+ sourceType: "module",
154
+ errorRecovery: false,
155
+ }).program;
156
+ } catch (error) {
157
+ throw new ScheduleError(
158
+ `precheck_script does not parse as a JavaScript module: ${error instanceof Error ? error.message || error.name : String(error)}`,
159
+ );
160
+ }
161
+ const exportsDefault = program.body.some(
162
+ (node) =>
163
+ node.type === "ExportDefaultDeclaration" ||
164
+ (node.type === "ExportNamedDeclaration" &&
165
+ node.specifiers.some((specifier) =>
166
+ specifier.exported.type === "Identifier"
167
+ ? specifier.exported.name === "default"
168
+ : specifier.exported.value === "default",
169
+ )),
170
+ );
171
+ if (!exportsDefault)
172
+ throw new ScheduleError(
173
+ "precheck_script needs a default export: export default async (context) => ({ wake: false, note }) or ({ wake: true, context })",
174
+ );
175
+ return { script, calls: mcpCalls(program) };
176
+ }
177
+
178
+ /** Checks a precheck script without running it, as `readPrecheckScript` does; returns it. */
179
+ export function checkPrecheckScript(script: unknown): string {
180
+ return readPrecheckScript(script).script;
181
+ }
182
+
183
+ function mcpCalls(program: Program): PrecheckScriptCall[] {
184
+ const calls: PrecheckScriptCall[] = [];
185
+ const allowed = new Set<Node>();
186
+ for (const [node] of walk(program)) {
187
+ if (
188
+ node.type !== "CallExpression" ||
189
+ node.callee.type !== "MemberExpression" ||
190
+ node.callee.computed ||
191
+ node.callee.object.type !== "Identifier" ||
192
+ node.callee.object.name !== "mcp" ||
193
+ node.callee.property.type !== "Identifier" ||
194
+ !["call", "json"].includes(node.callee.property.name)
195
+ )
196
+ continue;
197
+ const [serverArg, toolArg, argsArg, ...rest] = node.arguments;
198
+ const server = literal(serverArg);
199
+ const tool = literal(toolArg);
200
+ if (typeof server !== "string" || typeof tool !== "string")
201
+ throw new ScheduleError(
202
+ `precheck_script computes a server or tool name for mcp.${node.callee.property.name}; ${REACH}`,
203
+ );
204
+ if (rest.length > 0)
205
+ throw new ScheduleError(
206
+ `precheck_script passes mcp.${node.callee.property.name} more than three arguments; ${REACH}`,
207
+ );
208
+ const args = argsArg === undefined ? {} : literal(argsArg);
209
+ calls.push({
210
+ server,
211
+ tool,
212
+ ...(args !== NOT_LITERAL &&
213
+ typeof args === "object" &&
214
+ args !== null &&
215
+ !Array.isArray(args)
216
+ ? { args }
217
+ : {}),
218
+ });
219
+ allowed.add(node.callee.object);
220
+ }
221
+ for (const [node, parent, key] of walk(program)) {
222
+ if (node.type === "Identifier") {
223
+ if (node.name === "arguments" || node.name === "eval")
224
+ throw new ScheduleError(
225
+ `precheck_script may not use ${node.name}; ${REACH}`,
226
+ );
227
+ if (node.name !== "mcp" || allowed.has(node)) continue;
228
+ // `{ mcp }` taken from the context, under its own name.
229
+ if (
230
+ parent?.type === "ObjectProperty" &&
231
+ key === "value" &&
232
+ parent.key.type === "Identifier" &&
233
+ parent.key.name === "mcp" &&
234
+ !parent.computed
235
+ )
236
+ continue;
237
+ // A property key named mcp, such as the `mcp` of `{ mcp }`, is no use of it.
238
+ if (
239
+ parent?.type === "ObjectProperty" &&
240
+ key === "key" &&
241
+ !parent.computed
242
+ )
243
+ continue;
244
+ if (parent?.type === "MemberExpression" && key === "property") {
245
+ if (!parent.computed)
246
+ throw new ScheduleError(
247
+ `precheck_script reads .mcp from an object; ${REACH}`,
248
+ );
249
+ continue;
250
+ }
251
+ throw new ScheduleError(
252
+ `precheck_script uses mcp other than in a call; ${REACH}`,
253
+ );
254
+ }
255
+ if (
256
+ node.type === "ObjectProperty" &&
257
+ !node.computed &&
258
+ node.key.type === "Identifier" &&
259
+ node.key.name === "mcp" &&
260
+ parent?.type === "ObjectPattern" &&
261
+ !(node.value.type === "Identifier" && node.value.name === "mcp")
262
+ )
263
+ throw new ScheduleError(`precheck_script renames mcp; ${REACH}`);
264
+ if (
265
+ node.type === "MemberExpression" &&
266
+ node.computed &&
267
+ node.property.type === "StringLiteral" &&
268
+ node.property.value === "mcp"
269
+ )
270
+ throw new ScheduleError(`precheck_script reads ["mcp"]; ${REACH}`);
271
+ if (
272
+ node.type === "MemberExpression" &&
273
+ node.computed &&
274
+ node.object.type === "Identifier" &&
275
+ node.object.name === "mcp"
276
+ )
277
+ throw new ScheduleError(`precheck_script uses mcp[...]; ${REACH}`);
278
+ }
279
+ return calls;
280
+ }
281
+
282
+ /**
283
+ * The tools a script calls, each with what the host's hold rules say a call of it would do. A call
284
+ * whose arguments are computed is judged by an empty input, or by a rule that says it may hold it.
285
+ */
286
+ export function precheckScriptTools(
287
+ script: unknown,
288
+ options: {
289
+ toolName: (server: string, tool: string) => string;
290
+ holds: HoldCheck;
291
+ context?: HoldContext;
292
+ },
293
+ ): PrecheckTool[] {
294
+ const tools = new Map<string, PrecheckTool>();
295
+ for (const call of readPrecheckScript(script).calls) {
296
+ const name = options.toolName(call.server, call.tool);
297
+ const context = options.context ?? {};
298
+ let held: string | undefined;
299
+ if (call.args) held = options.holds(name, call.args, context);
300
+ else {
301
+ held = options.holds(name, {}, context);
302
+ const rule =
303
+ held === undefined ? options.holds.mayHold?.(name) : undefined;
304
+ if (rule !== undefined) held = messages().precheckToolMayHold(name, rule);
305
+ }
306
+ const key = `${call.server}\u0000${call.tool}`;
307
+ const known = tools.get(key);
308
+ if (!known)
309
+ tools.set(key, {
310
+ server: call.server,
311
+ tool: call.tool,
312
+ ...(held ? { held } : {}),
313
+ });
314
+ else if (held && !known.held) known.held = held;
315
+ }
316
+ return [...tools.values()];
317
+ }
318
+
319
+ const SCHEDULE_TOOLS = new Set(["schedule_create", "schedule_update"]);
320
+
321
+ /**
322
+ * Holds saving a precheck script that calls a held tool, so it is approved once, as each of its
323
+ * calls would be, by someone who could approve those calls; the scheduled runs then do not ask
324
+ * again. Contributed by the schedule tools' plugin; `holds` is the host's whole chain and `tiers`
325
+ * the host's tool tiers, read when a call is judged.
326
+ */
327
+ export function precheckScriptHoldRule(options: {
328
+ prechecks: () => Partial<Pick<PrecheckRegistry, "scriptRunner">> | undefined;
329
+ holds: () => HoldCheck;
330
+ tiers?: () => ToolTiers;
331
+ }): HoldRule {
332
+ let judging = false;
333
+ /** The held tools a script being saved calls, by the names the hold rules know them by. */
334
+ const heldCalls = (
335
+ tool: string,
336
+ input: Record<string, unknown>,
337
+ context: HoldContext,
338
+ ): { name: string; held: string }[] => {
339
+ const script = input.precheck_script;
340
+ if (!SCHEDULE_TOOLS.has(tool) || typeof script !== "string" || judging)
341
+ return [];
342
+ const runner = options.prechecks()?.scriptRunner?.();
343
+ if (!runner) return [];
344
+ judging = true;
345
+ try {
346
+ return precheckScriptTools(script, {
347
+ toolName: (server, name) => runner.toolName(server, name),
348
+ holds: options.holds(),
349
+ context,
350
+ }).flatMap((t) =>
351
+ t.held
352
+ ? [{ name: runner.toolName(t.server, t.tool), held: t.held }]
353
+ : [],
354
+ );
355
+ } catch {
356
+ // The tool refuses the script itself, with the reason.
357
+ return [];
358
+ } finally {
359
+ judging = false;
360
+ }
361
+ };
362
+ return {
363
+ name: "precheck-scripts",
364
+ describe(tool, input, context) {
365
+ const held = heldCalls(tool, input, context);
366
+ if (held.length === 0) return undefined;
367
+ return messages().precheckScriptHeld(
368
+ {
369
+ ...(typeof input.title === "string" && input.title
370
+ ? { title: input.title }
371
+ : {}),
372
+ ...(typeof input.id === "number" ? { id: input.id } : {}),
373
+ },
374
+ held.map((call) => call.held),
375
+ );
376
+ },
377
+ // Whoever approves must be able to approve each held call the script makes.
378
+ approvalTier(tool, input, context) {
379
+ const tiers = options.tiers?.();
380
+ return heldCalls(tool, input, context).reduce<Tier | undefined>(
381
+ (tier, call) =>
382
+ higherTier(tier, tiers ? tiers.minTier(call.name) : "owner"),
383
+ undefined,
384
+ );
385
+ },
386
+ };
387
+ }
@@ -1,6 +1,4 @@
1
- import { parse } from "@babel/parser";
2
1
  import type { ChannelKey } from "../../domain/conversation.ts";
3
- import { ScheduleError } from "../../domain/errors.ts";
4
2
  import { PluginError } from "../../errors.ts";
5
3
  import type { Tier } from "../../speakers.ts";
6
4
  import type { Schedule } from "./schedule-store.ts";
@@ -8,8 +6,10 @@ import type { Schedule } from "./schedule-store.ts";
8
6
  /** How long a precheck may run before it counts as failed, unless it sets its own `timeoutMs`. */
9
7
  export const PRECHECK_TIMEOUT_MS = 60_000;
10
8
 
11
- /** The longest precheck script a schedule may carry, in characters. */
12
- export const PRECHECK_SCRIPT_CHARS = 8_000;
9
+ export {
10
+ checkPrecheckScript,
11
+ PRECHECK_SCRIPT_CHARS,
12
+ } from "./precheck-tools.ts";
13
13
 
14
14
  /** What a finding names a schedule's own script by, in its heading and status. */
15
15
  export const SCRIPT_PRECHECK = "script";
@@ -69,6 +69,8 @@ export interface PrecheckScope {
69
69
 
70
70
  /** What a schedule's precheck script runs with when its schedule falls due. */
71
71
  export interface PrecheckScriptContext extends PrecheckContext {
72
+ /** The MCP tools the script may call, as approved when it was saved; the runner must refuse every other call. */
73
+ tools: readonly { server: string; tool: string }[];
72
74
  /** The host's IANA time zone, such as `Asia/Taipei`. */
73
75
  timeZone: string;
74
76
  /** The date in that zone when it fired, `YYYY-MM-DD`, so a script never reads a UTC date by mistake. */
@@ -88,6 +90,12 @@ export interface PrecheckScriptRunner {
88
90
  ): PrecheckResult | Promise<PrecheckResult>;
89
91
  /** How long a script may run; default 60 seconds. A timeout counts as a throw. */
90
92
  timeoutMs?: number;
93
+ /**
94
+ * The name the host's hold rules know a script's `mcp.call(server, tool)` by: the name its own
95
+ * agent calls that tool by. A script that calls a tool its rules hold needs the owner's approval
96
+ * to be saved.
97
+ */
98
+ toolName(server: string, tool: string): string;
91
99
  /**
92
100
  * How to write a script for a schedule in this scope, and what it may call there, shown to the
93
101
  * model by schedule_list.
@@ -134,10 +142,11 @@ export function memoryPrecheckRegistry(): PrecheckRegistry {
134
142
  );
135
143
  if (
136
144
  typeof given?.run !== "function" ||
137
- typeof given.describe !== "function"
145
+ typeof given.describe !== "function" ||
146
+ typeof given.toolName !== "function"
138
147
  )
139
148
  throw new PluginError(
140
- "a precheck script runner needs run and describe functions.",
149
+ "a precheck script runner needs run, describe, and toolName functions.",
141
150
  );
142
151
  if (
143
152
  given.timeoutMs !== undefined &&
@@ -180,47 +189,6 @@ export function memoryPrecheckRegistry(): PrecheckRegistry {
180
189
  };
181
190
  }
182
191
 
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
-
224
192
  function errorText(error: unknown): string {
225
193
  return error instanceof Error ? error.message || error.name : String(error);
226
194
  }
@@ -4,6 +4,7 @@ import type { ChannelKey } from "../../domain/conversation.ts";
4
4
  import { ScheduleError } from "../../domain/errors.ts";
5
5
  import type { ScheduleStore } from "../../services.ts";
6
6
  import type { Tier } from "../../speakers.ts";
7
+ import type { PrecheckTool } from "./precheck-tools.ts";
7
8
  import type { Recurrence } from "./recurrence.ts";
8
9
 
9
10
  export interface Schedule {
@@ -24,6 +25,11 @@ export interface Schedule {
24
25
  precheck?: string;
25
26
  /** 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
27
  precheckScript?: string;
28
+ /**
29
+ * The MCP tools its script may call, with those the owner approved as held when it was saved;
30
+ * absent for a script saved before tools were recorded, which must be saved again if it calls a held tool.
31
+ */
32
+ precheckTools?: PrecheckTool[];
27
33
  lastRun?: Date;
28
34
  lastStatus?: string;
29
35
  }
@@ -42,6 +48,8 @@ export interface NewSchedule {
42
48
  precheck?: string;
43
49
  /** A precheck script, run in the host's sandbox before each turn; not with `precheck`. */
44
50
  precheckScript?: string;
51
+ /** The tools the script may call, as approved; required with `precheckScript`. */
52
+ precheckTools?: PrecheckTool[];
45
53
  }
46
54
 
47
55
  export interface ScheduleChange {
@@ -53,6 +61,8 @@ export interface ScheduleChange {
53
61
  precheck?: string | null;
54
62
  /** A precheck script to run before each turn; null removes it. */
55
63
  precheckScript?: string | null;
64
+ /** The tools a new script may call, as approved; required with a new `precheckScript`. */
65
+ precheckTools?: PrecheckTool[];
56
66
  }
57
67
 
58
68
  interface Row {
@@ -70,6 +80,7 @@ interface Row {
70
80
  created_at: Date;
71
81
  precheck: string | null;
72
82
  precheck_script: string | null;
83
+ precheck_tools: string | null;
73
84
  last_run: Date | null;
74
85
  last_status: string | null;
75
86
  }
@@ -90,6 +101,10 @@ function toSchedule(row: Row): Schedule {
90
101
  createdAt: row.created_at,
91
102
  ...(row.precheck ? { precheck: row.precheck } : {}),
92
103
  ...(row.precheck_script ? { precheckScript: row.precheck_script } : {}),
104
+ ...(row.precheck_script && row.precheck_tools
105
+ ? // pi-lens-ignore: unchecked-throwing-call — this store wrote the JSON; a corrupt row should fail loudly
106
+ { precheckTools: JSON.parse(row.precheck_tools) as PrecheckTool[] }
107
+ : {}),
93
108
  ...(row.last_run ? { lastRun: row.last_run } : {}),
94
109
  ...(row.last_status ? { lastStatus: row.last_status } : {}),
95
110
  };
@@ -145,6 +160,13 @@ export class PgScheduleStore implements ScheduleStore {
145
160
  await sql`ALTER TABLE schedules ADD COLUMN IF NOT EXISTS precheck_script text`;
146
161
  },
147
162
  },
163
+ {
164
+ // Scripts saved before have none, and their tools are checked when they next run.
165
+ name: "schedules-precheck-tools",
166
+ up: async (sql) => {
167
+ await sql`ALTER TABLE schedules ADD COLUMN IF NOT EXISTS precheck_tools text`;
168
+ },
169
+ },
148
170
  ];
149
171
  }
150
172
 
@@ -158,13 +180,18 @@ export class PgScheduleStore implements ScheduleStore {
158
180
  throw new ScheduleError(
159
181
  "a schedule has a precheck or a precheck script, not both",
160
182
  );
183
+ if (schedule.precheckScript && !schedule.precheckTools)
184
+ throw new ScheduleError(
185
+ "a precheck script is saved with the tools it may call",
186
+ );
161
187
  const rows: Row[] = await this.#sql`
162
188
  INSERT INTO schedules (channel_key, mode, title, prompt, recurrence, next_run,
163
- created_by_id, created_by_name, created_tier, precheck, precheck_script)
189
+ created_by_id, created_by_name, created_tier, precheck, precheck_script, precheck_tools)
164
190
  VALUES (${schedule.channel}, ${schedule.target}, ${schedule.title}, ${schedule.prompt},
165
191
  ${JSON.stringify(schedule.recurrence)}, ${schedule.nextRun},
166
192
  ${schedule.createdById}, ${schedule.createdByName}, ${schedule.createdTier},
167
- ${schedule.precheck ?? null}, ${schedule.precheckScript ?? null})
193
+ ${schedule.precheck ?? null}, ${schedule.precheckScript ?? null},
194
+ ${schedule.precheckScript ? JSON.stringify(schedule.precheckTools) : null})
168
195
  RETURNING *`;
169
196
  const [row] = rows;
170
197
  if (!row) throw new Error("the schedule insert returned no row");
@@ -212,6 +239,18 @@ export class PgScheduleStore implements ScheduleStore {
212
239
  : change.precheckScript === undefined
213
240
  ? (current.precheckScript ?? null)
214
241
  : change.precheckScript;
242
+ if (change.precheckScript && !change.precheckTools)
243
+ throw new ScheduleError(
244
+ "a precheck script is saved with the tools it may call",
245
+ );
246
+ // A new script brings its own tools; one kept keeps its tools; no script, no tools.
247
+ const precheckTools = !precheckScript
248
+ ? null
249
+ : change.precheckScript
250
+ ? JSON.stringify(change.precheckTools)
251
+ : current.precheckTools
252
+ ? JSON.stringify(current.precheckTools)
253
+ : null;
215
254
  const rows: Row[] = await this.#sql`
216
255
  UPDATE schedules SET
217
256
  title = ${change.title ?? current.title},
@@ -219,7 +258,8 @@ export class PgScheduleStore implements ScheduleStore {
219
258
  recurrence = ${JSON.stringify(change.recurrence ?? current.recurrence)},
220
259
  next_run = ${change.nextRun ?? current.nextRun},
221
260
  precheck = ${precheck},
222
- precheck_script = ${precheckScript}
261
+ precheck_script = ${precheckScript},
262
+ precheck_tools = ${precheckTools}
223
263
  WHERE id = ${id}
224
264
  RETURNING *`;
225
265
  return rows[0] ? toSchedule(rows[0]) : undefined;
@@ -1,13 +1,14 @@
1
1
  import type { BackgroundTarget } from "../../contract/channels.ts";
2
2
  import type { ChannelKey } from "../../domain/conversation.ts";
3
3
  import { ScheduleError } from "../../domain/errors.ts";
4
+ import type { HoldCheck } from "../../holds.ts";
4
5
  import { messages } from "../../i18n/index.ts";
5
6
  import type { ScheduleStore } from "../../services.ts";
6
7
  import type { ScheduleToolName } from "../../shared/schedule-tools.ts";
7
8
  import { type Tier, tierAtLeast } from "../../speakers.ts";
8
9
  import { timeZone, zonedStamp } from "../../time.ts";
10
+ import { type PrecheckTool, precheckScriptTools } from "./precheck-tools.ts";
9
11
  import {
10
- checkPrecheckScript,
11
12
  PRECHECK_SCRIPT_CHARS,
12
13
  type PrecheckFinding,
13
14
  type PrecheckRegistry,
@@ -44,6 +45,11 @@ export interface ScheduleToolContext {
44
45
  */
45
46
  prechecks?: Pick<PrecheckRegistry, "get" | "list"> &
46
47
  Partial<Pick<PrecheckRegistry, "scriptRunner">>;
48
+ /**
49
+ * The host's hold rules, which mark the tools a saved script calls that the owner approved.
50
+ * The approval itself is the confirmation gate's, over this very call.
51
+ */
52
+ holds?: () => HoldCheck;
47
53
  }
48
54
 
49
55
  /** The limits of the context's target; a target without them may not schedule. */
@@ -95,15 +101,41 @@ function precheckName(ctx: ScheduleToolContext, value: unknown): string {
95
101
  return name;
96
102
  }
97
103
 
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?.()) {
104
+ /**
105
+ * A precheck script the host's sandbox runs, with the tools it calls: those the hold rules hold
106
+ * were approved by the owner through the confirmation gate before this call ran. Refused when the
107
+ * host has no runner for scripts.
108
+ */
109
+ function precheckScript(
110
+ ctx: ScheduleToolContext,
111
+ value: unknown,
112
+ ): { script: string; tools: PrecheckTool[] } {
113
+ const runner = ctx.prechecks?.scriptRunner?.();
114
+ if (!runner) {
101
115
  const names = (ctx.prechecks?.list() ?? []).map((p) => p.name);
102
116
  throw new ScheduleError(
103
117
  `this host runs no precheck scripts; ${names.length ? `attach a registered precheck instead: ${names.join(", ")}` : "it registers no prechecks either"}`,
104
118
  );
105
119
  }
106
- return checkPrecheckScript(value);
120
+ const script = typeof value === "string" ? value : "";
121
+ const tools = precheckScriptTools(value, {
122
+ toolName: (server, tool) => runner.toolName(server, tool),
123
+ holds: ctx.holds?.() ?? (() => undefined),
124
+ });
125
+ return { script, tools };
126
+ }
127
+
128
+ /** The tools a script may call, the owner's approvals marked. */
129
+ function toolsText(tools: readonly PrecheckTool[] | undefined): string {
130
+ if (!tools)
131
+ return "not recorded; saved before tools were, so it must be saved again if it calls a held tool";
132
+ if (tools.length === 0) return "none";
133
+ return tools
134
+ .map(
135
+ (t) =>
136
+ `${t.server}/${t.tool}${t.held ? ` (approved by the owner: ${t.held})` : ""}`,
137
+ )
138
+ .join(", ");
107
139
  }
108
140
 
109
141
  /** The prechecks a schedule may name or write, for schedule_list; empty when the host offers neither. */
@@ -181,7 +213,7 @@ function line(schedule: Schedule): string {
181
213
  const precheck = schedule.precheck
182
214
  ? `; precheck ${schedule.precheck}`
183
215
  : schedule.precheckScript
184
- ? `; precheck script (${schedule.precheckScript.length} characters)`
216
+ ? `; precheck script (${schedule.precheckScript.length} characters; tools: ${toolsText(schedule.precheckTools)})`
185
217
  : "";
186
218
  const last = schedule.lastRun
187
219
  ? `; last run ${zonedStamp(schedule.lastRun)} (${schedule.lastStatus ?? "?"})`
@@ -246,7 +278,9 @@ export async function callScheduleTool(
246
278
  createdByName: ctx.author.name,
247
279
  createdTier: ctx.author.tier ?? "owner",
248
280
  ...(precheck ? { precheck } : {}),
249
- ...(script ? { precheckScript: script } : {}),
281
+ ...(script
282
+ ? { precheckScript: script.script, precheckTools: script.tools }
283
+ : {}),
250
284
  });
251
285
  const checked = precheck
252
286
  ? `; precheck ${precheck} runs first`
@@ -293,11 +327,12 @@ export async function callScheduleTool(
293
327
  if (input.precheck !== undefined)
294
328
  change.precheck =
295
329
  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);
330
+ if (input.precheck_script === null) change.precheckScript = null;
331
+ else if (input.precheck_script !== undefined) {
332
+ const script = precheckScript(ctx, input.precheck_script);
333
+ change.precheckScript = script.script;
334
+ change.precheckTools = script.tools;
335
+ }
301
336
  if (change.precheck) change.precheckScript = null;
302
337
  if (change.precheckScript) change.precheck = null;
303
338
  if (Object.keys(change).length === 0)
@@ -1,7 +1,9 @@
1
1
  import type { ScheduledOutcome } from "../../contract/channels.ts";
2
+ import type { HoldCheck } from "../../holds.ts";
2
3
  import type { Logger } from "../../log.ts";
3
4
  import type { ScheduleStore } from "../../services.ts";
4
5
  import { timeZone, zonedStamp } from "../../time.ts";
6
+ import { type PrecheckTool, precheckScriptTools } from "./precheck-tools.ts";
5
7
  import {
6
8
  type Precheck,
7
9
  type PrecheckFinding,
@@ -36,6 +38,11 @@ export interface SchedulerOptions {
36
38
  /** The host's prechecks and script runner; without them, a schedule that has one runs with that as its error. */
37
39
  prechecks?: Pick<PrecheckRegistry, "get"> &
38
40
  Partial<Pick<PrecheckRegistry, "scriptRunner">>;
41
+ /**
42
+ * The host's hold rules, read when a script saved before its tools were recorded runs: it runs
43
+ * only if it calls no held tool. Without them, such a script does not run.
44
+ */
45
+ holds?: () => HoldCheck;
39
46
  /** Posts a skipping precheck's note in the schedule's channel as the bot's own message, which starts no turn. */
40
47
  notify?: (schedule: Schedule, note: string) => Promise<void>;
41
48
  logger: Logger;
@@ -214,6 +221,8 @@ export class Scheduler {
214
221
  const runner = prechecks?.scriptRunner?.();
215
222
  if (!runner)
216
223
  return "this host has no precheck script runner, so the script did not run";
224
+ const tools = this.#tools(schedule, script, runner.toolName.bind(runner));
225
+ if (typeof tools === "string") return tools;
217
226
  const zone = timeZone();
218
227
  return {
219
228
  name: SCRIPT_PRECHECK,
@@ -226,10 +235,40 @@ export class Scheduler {
226
235
  ...context,
227
236
  timeZone: zone,
228
237
  today: zonedStamp(firedAt).slice(0, 10),
238
+ tools: tools.map(({ server, tool }) => ({ server, tool })),
229
239
  }),
230
240
  };
231
241
  }
232
242
 
243
+ /**
244
+ * The tools a script may call: those approved when it was saved, or, for a script saved before
245
+ * they were recorded, those it calls when none of them is held. Otherwise why it cannot run.
246
+ */
247
+ #tools(
248
+ schedule: Schedule,
249
+ script: string,
250
+ toolName: (server: string, tool: string) => string,
251
+ ): PrecheckTool[] | string {
252
+ if (schedule.precheckTools) return schedule.precheckTools;
253
+ const save =
254
+ "save it again with schedule_update, so the owner can approve the tools it calls";
255
+ const holds = this.#options.holds?.();
256
+ if (!holds)
257
+ return `this script was saved before its tools were recorded, and this host cannot check them; ${save}`;
258
+ let tools: PrecheckTool[];
259
+ try {
260
+ tools = precheckScriptTools(script, { toolName, holds });
261
+ } catch (error) {
262
+ return `this script was saved before its tools were recorded and cannot be checked now (${error instanceof Error ? error.message : String(error)}); fix it and ${save}`;
263
+ }
264
+ const held = tools.flatMap((t) =>
265
+ t.held ? [`${t.server}/${t.tool}`] : [],
266
+ );
267
+ if (held.length > 0)
268
+ return `this script was saved before its tools were recorded and calls tools that need the owner's approval (${held.join(", ")}), so it did not run; ${save}`;
269
+ return tools;
270
+ }
271
+
233
272
  /** Runs the schedule's precheck; a missing one fails like a throw, so the turn still runs. */
234
273
  async #precheck(
235
274
  schedule: Schedule,
@@ -3,6 +3,7 @@ import { Type } from "typebox";
3
3
  import { OWNER_TARGET } from "../../agents/agent-claim.ts";
4
4
  import type { ChannelKey } from "../../domain/conversation.ts";
5
5
  import { ScheduleError } from "../../domain/errors.ts";
6
+ import type { HoldCheck } from "../../holds.ts";
6
7
  import { activeLocale } from "../../i18n/index.ts";
7
8
  import type { OwnerIdentity } from "../../identity.ts";
8
9
  import {
@@ -30,6 +31,8 @@ export interface OwnerSchedules {
30
31
  /** The host's prechecks a schedule may name, and the runner of scripts it may carry; without them, none can be attached. */
31
32
  prechecks?: Pick<PrecheckRegistry, "get" | "list"> &
32
33
  Partial<Pick<PrecheckRegistry, "scriptRunner">>;
34
+ /** The host's hold rules, which mark a saved script's approved tools. */
35
+ holds?: () => HoldCheck;
33
36
  }
34
37
 
35
38
  /** Finds another agent's channel, for reading its schedules; throws ScheduleError when there is none. */
@@ -62,7 +65,7 @@ export function schedulesExtension(
62
65
  /** The person the running turn is for; their schedules run at their tier. */
63
66
  speaker: () => Speaker | undefined = () => undefined,
64
67
  ): ExtensionFactory {
65
- const { store, owner, channelFor, prechecks } = schedules;
68
+ const { store, owner, channelFor, prechecks, holds } = schedules;
66
69
  const defs = scheduleToolSpecs({
67
70
  locale: activeLocale(),
68
71
  timeZone: timeZone(),
@@ -85,6 +88,7 @@ export function schedulesExtension(
85
88
  author: speaker() ?? owner,
86
89
  now: new Date(),
87
90
  ...(prechecks ? { prechecks } : {}),
91
+ ...(holds ? { holds } : {}),
88
92
  },
89
93
  spec.name,
90
94
  input,
@@ -4,7 +4,7 @@ import type {
4
4
  PendingConfirmation,
5
5
  } from "../../domain/conversation.ts";
6
6
  import type { OwnerPrompts } from "../../domain/owner-prompts.ts";
7
- import type { HoldCheck } from "../../holds.ts";
7
+ import { type HoldCheck, higherTier } from "../../holds.ts";
8
8
  import { messages } from "../../i18n/index.ts";
9
9
  import { type OwnerIdentity, ownerWords } from "../../identity.ts";
10
10
  import type { ToolTiers } from "../../tool-tiers.ts";
@@ -141,7 +141,7 @@ export class ConfirmationGate {
141
141
  messages().confirmTitle(ask.asker),
142
142
  approvalCard(call),
143
143
  ask.signal,
144
- this.#tiers?.minTier(call.tool),
144
+ higherTier(this.#tiers?.minTier(call.tool), call.minTier),
145
145
  );
146
146
  if (answer === "approved") return { approvedOnCard: true };
147
147
  if (answer === "declined") {
@@ -156,13 +156,17 @@ export class ConfirmationGate {
156
156
 
157
157
  /** The call when it needs the owner's confirmation and no approval releases it. */
158
158
  #needing(tool: string, input: Record<string, unknown>): HeldCall | undefined {
159
- const action = this.#holds(
160
- tool,
161
- input,
162
- this.#workspace === undefined ? {} : { workspace: this.#workspace },
163
- );
159
+ const context =
160
+ this.#workspace === undefined ? {} : { workspace: this.#workspace };
161
+ const action = this.#holds(tool, input, context);
164
162
  if (!action) return undefined;
165
- const call: HeldCall = { tool, input: canonicalJson(input), action };
163
+ const minTier = this.#holds.approvalTier?.(tool, input, context);
164
+ const call: HeldCall = {
165
+ tool,
166
+ input: canonicalJson(input),
167
+ action,
168
+ ...(minTier ? { minTier } : {}),
169
+ };
166
170
  const approved = this.#approved.findIndex(
167
171
  (c) => c.tool === call.tool && c.input === call.input,
168
172
  );
@@ -69,6 +69,8 @@ export function fakeScriptRunner(
69
69
  answer: FakeScriptAnswer,
70
70
  options: {
71
71
  describe?: (scope: PrecheckScope) => string;
72
+ /** The name hold rules know a script's call by; default `${server}-${tool}`. */
73
+ toolName?: (server: string, tool: string) => string;
72
74
  timeoutMs?: number;
73
75
  } = {},
74
76
  ): FakeScriptRunner {
@@ -79,6 +81,7 @@ export function fakeScriptRunner(
79
81
  ? {}
80
82
  : { timeoutMs: options.timeoutMs }),
81
83
  describe: options.describe ?? (() => "A test script runner."),
84
+ toolName: options.toolName ?? ((server, tool) => `${server}-${tool}`),
82
85
  run: async (script, context) => {
83
86
  calls.push({ script, context });
84
87
  if (answer instanceof Error) throw answer;
package/src/index.ts CHANGED
@@ -126,6 +126,7 @@ export type {
126
126
  PromptMemory,
127
127
  } from "./core/modules/memory/owner-memory-store.ts";
128
128
  export { MEMORY_KINDS } from "./core/modules/memory/owner-memory-store.ts";
129
+ export type { PrecheckTool } from "./core/modules/schedules/precheck-tools.ts";
129
130
  export type {
130
131
  Precheck,
131
132
  PrecheckContext,