feat(core): show a schedule's shell action and last run in its tools

schedule_create, schedule_list, pause and resume now return the canonical action (a prompt or a shell run with cwd and timeout) and the last run's result, and their one-line summaries show the command and its exit code instead of inventing a prompt. Docs and the experimental flag description say a schedule can run a command that reads OPENCODE_SCHEDULE_MESSAGE.
This commit is contained in:
Kit Langton
2026-11-04 17:12:42 -08:00
Unverified
parent 59cb2d22a7
commit e6ddeff34d
5 changed files with 134 additions and 14 deletions
@@ -1,11 +1,11 @@
Manage scheduled prompts for this session.
Manage scheduled prompts and commands for this session.
Request: $ARGUMENTS
Use the schedule tools in the `opencode` namespace. They only see this session's schedules.
- 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` (wake once then stop, 24h default timeout; matches immediately if already true) or `if` (gate each recurring slot with no model call; baselines `state` at creation without waking) using either `{ run: "..." }` for a shell check or `{ code: "..." }` for a Code Mode JavaScript check calling `tools.<path>(...)` statically (no `fetch`, 30s / 20 calls / 32 KB caps) and returning a boolean or `{ wake, state?, message? }`. Code checks run once at creation with `state = null` and fail `schedule_create` if they throw. 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.
- 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` (wake once then stop, 24h default timeout; matches immediately if already true) or `if` (gate each recurring slot with no model call; baselines `state` at creation without waking) using either `{ run: "..." }` for a shell check or `{ code: "..." }` for a Code Mode JavaScript check calling `tools.<path>(...)` statically (no `fetch`, 30s / 20 calls / 32 KB caps) and returning a boolean or `{ wake, state?, message? }`. Code checks run once at creation with `state = null` and fail `schedule_create` if they throw. 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. To run something instead, pass `run` (a shell command, with no model call) instead of `prompt`, with optional `cwd` (relative to the session directory, defaulting to it) and `timeout` (defaults to 10 minutes); a command receives a check's message on stdin and in `OPENCODE_SCHEDULE_MESSAGE`. Pass exactly one of `prompt` or `run`.
- To pause, resume or delete one, find its id with `schedule_list`, then call `schedule_pause`, `schedule_resume` or `schedule_delete`.
- If the request is empty, call `schedule_list` and show this session's schedules: name, when, status and next run.
- If the request is empty, call `schedule_list` and show this session's schedules: name, when, status, next run, the command or prompt, and the last run's exit code.
Reply in one line: what changed and when it next fires.
+16 -2
View File
@@ -7,6 +7,7 @@ import { Node, makeGlobalNode } from "@opencode/util/effect/app-node"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Global } from "@opencode/util/global"
import {
Action,
Condition,
ConditionInput,
CreateInput,
@@ -16,7 +17,7 @@ import {
Info,
Outcome,
Request,
type RunResult,
RunResult,
type ShellAction,
Status,
type StoredCheck,
@@ -44,7 +45,20 @@ import { ScheduleAction } from "./schedule/action.js"
import { ScheduleCheck } from "./schedule/check.js"
import { ScheduleTable } from "./schedule/sql.js"
export { ID, Info, CreateInput, Request, Condition, ConditionInput, When, Status, Outcome, toCreateInput }
export {
Action,
ID,
Info,
CreateInput,
Request,
Condition,
ConditionInput,
When,
Status,
Outcome,
RunResult,
toCreateInput,
}
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Schedule.NotFoundError", {
scheduleID: ID,
+30 -7
View File
@@ -19,7 +19,7 @@ export const View = Schema.Struct({
tz: Schema.String,
if: Schedule.Condition.pipe(optional),
until: Schedule.Condition.pipe(optional),
prompt: Schema.String,
action: Schedule.Action,
status: Schedule.Status,
nextDueAt: Schema.String.pipe(optional),
endsAt: Schema.String.pipe(optional),
@@ -28,6 +28,7 @@ export const View = Schema.Struct({
lastFiredAt: Schema.String.pipe(optional),
lastOutcome: Schedule.Outcome.pipe(optional),
lastOutput: Schema.String.pipe(optional),
lastRun: Schedule.RunResult.pipe(optional),
})
export type View = typeof View.Type
@@ -78,7 +79,7 @@ export const Plugin = {
editor.add({
name: "schedule_create",
description:
"Schedule a prompt that wakes this session later, or a shell command that runs on its own: once after a delay, at a time, every interval, on a cron, or gated by an `if` / `until` shell (`{ run }`) or Code Mode (`{ code }`) check. Pass exactly one of `prompt` or `run`. Returns the schedule with its next run (or `done` immediately if `until` already matches).",
"Schedule a prompt that wakes this session later, or a shell command that runs on its own: once after a delay, at a time, every interval, on a cron, or gated by an `if` / `until` shell (`{ run }`) or Code Mode (`{ code }`) check. Pass exactly one of `prompt` or `run`; a command runs with no model call and receives a check's message on stdin and in `OPENCODE_SCHEDULE_MESSAGE`. Returns the schedule with its next run (or `done` immediately if `until` already matches).",
input: Schedule.Request,
output: View,
options: { namespace: "opencode", codemode: true },
@@ -95,7 +96,8 @@ export const Plugin = {
})
editor.add({
name: "schedule_list",
description: "List this session's schedules with their status and next run, and the current time.",
description:
"List this session's schedules with their status, next run, command or prompt, last run's exit code, and the current time.",
input: Schema.Struct({}),
output: ListOutput,
options: { namespace: "opencode", codemode: true },
@@ -157,7 +159,7 @@ export const Plugin = {
if (!enabled()) return
editor.add({
name: "schedule",
description: "schedule a prompt for later, or list, pause and delete this session's schedules",
description: "schedule a prompt or shell command for later, or list, pause and delete this session's schedules",
execute: (input) =>
ctx.session
.prompt({
@@ -186,6 +188,26 @@ function formatCondition(condition: Schedule.Condition): string {
}
}
function formatAction(action: Schedule.Action): string {
if (action.type === "prompt") return `prompt: ${JSON.stringify(shorten(action.prompt))}`
const details = [
action.cwd === undefined ? undefined : `cwd ${action.cwd}`,
action.timeout === undefined ? undefined : `timeout ${action.timeout}`,
].filter((detail): detail is string => detail !== undefined)
const suffix = details.length === 0 ? "" : ` (${details.join(", ")})`
return `run: ${JSON.stringify(shorten(action.run))}${suffix}`
}
/** An absent exit code means the command never finished: a timeout, a spawn error, or a process that died. */
function formatRun(run: Schedule.RunResult): string {
if (run.exitCode !== undefined) return `exit ${run.exitCode}`
return shorten(run.error ?? "no exit code yet")
}
function shorten(text: string): string {
return text.length > 80 ? `${text.slice(0, 79)}…` : text
}
/** One line per schedule, for models that read tool content rather than output (Code Mode off). */
function summary(view: View) {
const when = view.when
@@ -203,13 +225,13 @@ function summary(view: View) {
: view.if !== undefined
? `if ${formatCondition(view.if)}`
: undefined
const prompt = view.prompt.length > 80 ? `${view.prompt.slice(0, 79)}…` : view.prompt
return [
`${view.id}${view.name ? ` "${view.name}"` : ""}: ${trigger} (${view.tz})`,
gate,
view.status,
view.nextDueAt && `next ${view.nextDueAt}`,
`prompt: ${JSON.stringify(prompt)}`,
formatAction(view.action),
view.lastRun && `last run: ${formatRun(view.lastRun)}`,
view.lastOutput && `output: ${JSON.stringify(view.lastOutput)}`,
]
.filter(Boolean)
@@ -231,7 +253,7 @@ function toView(info: Schedule.Info): View {
tz: info.tz,
if: info.if,
until: info.until,
prompt: info.action.type === "prompt" ? info.action.prompt : `$ ${info.action.run}`,
action: info.action,
status: info.status,
nextDueAt: local(info.nextDueAt),
endsAt: local(info.endsAt),
@@ -240,5 +262,6 @@ function toView(info: Schedule.Info): View {
lastFiredAt: local(info.lastFiredAt),
lastOutcome: info.lastOutcome,
lastOutput: info.lastOutput,
lastRun: info.lastRun,
}
}
+84 -1
View File
@@ -172,7 +172,7 @@ describe("ScheduleTools", () => {
id: "sch_1",
name: "build",
when: { type: "in", in: "20 minutes" },
prompt: "check the build",
action: { type: "prompt", prompt: "check the build" },
status: "active",
nextDueAt: "2026-10-27T09:20:00.000-04:00",
})
@@ -256,6 +256,89 @@ describe("ScheduleTools", () => {
}),
)
it.effect("creates a command schedule with its directory and timeout", () =>
Effect.gen(function* () {
const { registry, rows } = yield* setup(new ConfigExperimental.Info({ schedules: true }))
const created = yield* run(registry, "schedule_create", {
name: "ci",
when: { every: "1 hour" },
run: "bun test",
cwd: "packages/core",
timeout: "5 minutes",
})
expect(created).toMatchObject({
id: "sch_1",
name: "ci",
action: { type: "shell", run: "bun test", cwd: "packages/core", timeout: "5 minutes" },
})
expect(created).not.toHaveProperty("prompt")
expect(rows.get(Schedule.ID.make("sch_1"))).toMatchObject({
sessionID,
action: { type: "shell", run: "bun test", cwd: "packages/core", timeout: "5 minutes" },
})
}),
)
it.effect("summarises a command, with its directory and timeout, instead of a prompt", () =>
Effect.gen(function* () {
const { registry } = yield* setup(new ConfigExperimental.Info({ schedules: true }))
expect(
yield* direct(registry, "schedule_create", { name: "ci", when: { every: "1 hour" }, run: "bun test" }),
).toBe(
'Scheduled: sch_1 "ci": every 1 hour (America/New_York), active, next 2026-10-27T09:20:00.000-04:00, run: "bun test"',
)
yield* direct(registry, "schedule_create", {
when: { every: "1 hour" },
run: "x".repeat(100),
cwd: "packages/core",
timeout: "5 minutes",
})
const list = yield* direct(registry, "schedule_list", {})
expect(list).toStartWith("Now: ")
expect(list?.split("\n").slice(1)).toEqual([
'sch_1 "ci": every 1 hour (America/New_York), active, next 2026-10-27T09:20:00.000-04:00, run: "bun test"',
`sch_2: every 1 hour (America/New_York), active, next 2026-10-27T09:20:00.000-04:00, run: "${"x".repeat(79)}…" (cwd packages/core, timeout 5 minutes)`,
])
}),
)
it.effect("shows each command's last run instead of assuming it succeeded", () =>
Effect.gen(function* () {
const { registry, rows } = yield* setup(new ConfigExperimental.Info({ schedules: true }))
yield* run(registry, "schedule_create", { when: { every: "1 hour" }, run: "bun test" })
const info = rows.get(Schedule.ID.make("sch_1"))!
const lastLine = (list: string | undefined) => list?.split("\n")[1]
rows.set(info.id, { ...info, lastRun: { exitCode: 7, durationMs: 12, output: "boom" } })
expect(yield* run(registry, "schedule_list", {})).toMatchObject({
schedules: [
{ action: { type: "shell", run: "bun test" }, lastRun: { exitCode: 7, durationMs: 12, output: "boom" } },
],
})
expect(lastLine(yield* direct(registry, "schedule_list", {}))).toContain("last run: exit 7")
rows.set(info.id, { ...info, lastRun: { durationMs: 600_000, output: "", error: "Timed out after 10 minutes" } })
expect(lastLine(yield* direct(registry, "schedule_list", {}))).toContain("last run: Timed out after 10 minutes")
// A run that never finished leaves the start marker: no exit code, so never read it as success.
rows.set(info.id, { ...info, lastRun: { durationMs: 0, output: "" } })
expect(lastLine(yield* direct(registry, "schedule_list", {}))).toContain("last run: no exit code yet")
}),
)
it.effect("rejects a prompt and a command together, or neither", () =>
Effect.gen(function* () {
const { registry, rows } = yield* setup(new ConfigExperimental.Info({ schedules: true }))
for (const input of [{ when: { in: "5 minutes" }, prompt: "p", run: "true" }, { when: { in: "5 minutes" } }]) {
expect(yield* call(registry, "schedule_create", input)).toMatchObject({ metadata: { error: true } })
}
expect(rows.size).toBe(0)
expect(yield* run(registry, "schedule_create", { when: { in: "5 minutes" }, prompt: "p" })).toMatchObject({
action: { type: "prompt", prompt: "p" },
})
}),
)
it.effect("treats another session's schedule as missing", () =>
Effect.gen(function* () {
const { registry, rows } = yield* setup(new ConfigExperimental.Info({ schedules: true }))
+1 -1
View File
@@ -13,7 +13,7 @@ export const registry = {
background_compaction:
"Enable experimental background compaction: summarize the conversation in the background as it nears the compaction threshold, so automatic compaction does not wait for the summary. Background summaries that are never used still cost tokens.",
media_blob: "Store new session media once in a deduplicated blob table instead of inline in every message row.",
schedules: "Enable experimental scheduled tasks that wake a session with a prompt.",
schedules: "Enable experimental scheduled tasks that wake a session with a prompt or run a shell command.",
artifacts: "Enable experimental artifacts backed by pluggable providers.",
} as const satisfies Record<string, string>
export type Key = keyof typeof registry