Skip to content

Commit c784d17

Browse files
authored
feat(core): gate scheduled prompts with shell checks (#60137)
1 parent 756f832 commit c784d17

20 files changed

Lines changed: 1482 additions & 90 deletions

File tree

‎packages/client/src/effect/api/api.ts‎

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/client/src/effect/generated/client.ts‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/client/src/promise/generated/client.ts‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/client/src/promise/generated/types.ts‎

Lines changed: 46 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/core/schema.json‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"version": "7",
33
"dialect": "sqlite",
4-
"id": "e5daa0af-cae7-4057-919a-ebf44dfced0a",
5-
"prevIds": ["dc16dd12-cb76-432f-8108-0c3aadaf54bb"],
4+
"id": "0ac88673-a402-45a0-b931-e694d59ccefa",
5+
"prevIds": ["e5daa0af-cae7-4057-919a-ebf44dfced0a"],
66
"ddl": [
77
{
88
"name": "account_state",
@@ -792,6 +792,16 @@
792792
"entityType": "columns",
793793
"table": "schedule"
794794
},
795+
{
796+
"type": "text",
797+
"notNull": false,
798+
"autoincrement": false,
799+
"default": null,
800+
"generated": null,
801+
"name": "check",
802+
"entityType": "columns",
803+
"table": "schedule"
804+
},
795805
{
796806
"type": "text",
797807
"notNull": true,
@@ -862,6 +872,26 @@
862872
"entityType": "columns",
863873
"table": "schedule"
864874
},
875+
{
876+
"type": "integer",
877+
"notNull": true,
878+
"autoincrement": false,
879+
"default": "0",
880+
"generated": null,
881+
"name": "checks",
882+
"entityType": "columns",
883+
"table": "schedule"
884+
},
885+
{
886+
"type": "integer",
887+
"notNull": true,
888+
"autoincrement": false,
889+
"default": "0",
890+
"generated": null,
891+
"name": "failures",
892+
"entityType": "columns",
893+
"table": "schedule"
894+
},
865895
{
866896
"type": "integer",
867897
"notNull": false,
@@ -902,6 +932,16 @@
902932
"entityType": "columns",
903933
"table": "schedule"
904934
},
935+
{
936+
"type": "text",
937+
"notNull": false,
938+
"autoincrement": false,
939+
"default": null,
940+
"generated": null,
941+
"name": "state",
942+
"entityType": "columns",
943+
"table": "schedule"
944+
},
905945
{
906946
"type": "integer",
907947
"notNull": true,

‎packages/core/src/database/migration.gen.ts‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Effect } from "effect"
2+
import type { DatabaseMigration } from "../migration.js"
3+
4+
const migration: DatabaseMigration.Migration = {
5+
id: "20261029165540_schedule_checks",
6+
up(tx) {
7+
return Effect.gen(function* () {
8+
yield* tx.run(`ALTER TABLE \`schedule\` ADD \`check\` text;`)
9+
yield* tx.run(`ALTER TABLE \`schedule\` ADD \`checks\` integer DEFAULT 0 NOT NULL;`)
10+
yield* tx.run(`ALTER TABLE \`schedule\` ADD \`failures\` integer DEFAULT 0 NOT NULL;`)
11+
yield* tx.run(`ALTER TABLE \`schedule\` ADD \`state\` text;`)
12+
})
13+
},
14+
}
15+
16+
export default migration

‎packages/core/src/database/schema.gen.ts‎

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/core/src/permission.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,23 @@ export interface AskResult {
4848

4949
export { Event } from "@opencode/schema/permission"
5050

51+
/**
52+
* Rules that settle a request which would otherwise ask, for work nobody is present to answer, such as a schedule's
53+
* check. A request they do not allow is denied. Configured denies still win, as they are evaluated first.
54+
*/
55+
export const Unattended = Context.Reference<Permission.Ruleset | undefined>("@opencode/Permission/Unattended", {
56+
defaultValue: () => undefined,
57+
})
58+
5159
export class DeclinedError extends Schema.TaggedError<DeclinedError>()("Permission.DeclinedError", {}) {}
5260

5361
export class CorrectedError extends Schema.TaggedError<CorrectedError>()("Permission.CorrectedError", {
5462
feedback: Schema.String,
55-
}) {}
63+
}) {
64+
override get message() {
65+
return this.feedback
66+
}
67+
}
5668

5769
export class BlockedError extends Schema.TaggedError<BlockedError>()("Permission.BlockedError", {
5870
rules: Permission.Ruleset,
@@ -174,7 +186,17 @@ const layer = Layer.effect(
174186
source: input.source,
175187
effect,
176188
})
177-
return { effect: event.effect, message: event.message, rules: all }
189+
const unattended = yield* Unattended
190+
if (event.effect !== "ask" || unattended === undefined)
191+
return { effect: event.effect, message: event.message, rules: all }
192+
const settled = [...all, ...unattended]
193+
const allowed = input.resources.every((resource) => evaluate(input.action, resource, settled).effect === "allow")
194+
if (allowed) return { effect: "allow" as const, rules: all }
195+
return {
196+
effect: "deny" as const,
197+
message: `Permission ${input.action} needs approval, and nobody is present to give it`,
198+
rules: all,
199+
}
178200
})
179201

180202
function request(input: AssertInput, message?: string): Request {

‎packages/core/src/plugin/command/schedule.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Request: $ARGUMENTS
44

55
Use the schedule tools in the `opencode` namespace. They only see this session's schedules.
66

7-
- To schedule something, call `schedule_create`. Choose exactly one `when`: `{ in: "20 minutes" }`, `{ at: "2026-10-27T09:00" }`, `{ every: "1 hour" }` or `{ cron: "0 9 * * 1-5" }`. Wall-clock times use the configured time zone unless you pass `tz`. To stop a repeating schedule, add `ends`: `{ after: "8 hours" }`, `{ at: "..." }` or `{ times: 3 }`. Write `prompt` as the instruction you will receive when it fires, with enough context to act on it without this conversation.
7+
- To schedule something, call `schedule_create`. Choose `when` (`{ in: "20 minutes" }`, `{ at: "2026-10-27T09:00" }`, `{ every: "1 hour" }` or `{ cron: "0 9 * * 1-5" }`, optional with `until` which defaults to every 30s). Add `until: { run: "..." }` to wait once for a shell condition (exit 0, `is`, `matches`, or `changes: true`, defaulting to a 24h timeout), or `if: { run: "..." }` to gate each recurring slot without a model call. To stop a repeating schedule, add `ends`: `{ after: "8 hours" }`, `{ at: "..." }` or `{ times: 3 }`. Write `prompt` as the instruction you will receive when it wakes, with enough context to act on it without this conversation.
88
- To pause, resume or delete one, find its id with `schedule_list`, then call `schedule_pause`, `schedule_resume` or `schedule_delete`.
99
- If the request is empty, call `schedule_list` and show this session's schedules: name, when, status and next run.
1010

0 commit comments

Comments
 (0)