mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 20:52:01 +03:00
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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user