Compare commits

...
Author SHA1 Message Date
Ryan Vogel 705c3ab746 feat(core): let agents create recurring and one-shot schedules
Adds schedule_create, schedule_list and schedule_cancel tools in the
opencode namespace so the agent can schedule prompts back into its own
session. schedule_create accepts `every` (30s/15m/2h/1d), `at` (ISO 8601
with offset) for a single run, or both (`at` sets the first run).

SessionSchedule now stores multiple schedules per session, keyed by a
new `sch_` ID, and persists the absolute next fire time instead of only
the interval. This replaces the one-schedule-per-session model and fixes
restarts resetting the countdown: one-shots fire at their original time
after a restart, and overdue recurring schedules fire once then resume
their cadence rather than bursting. `/schedule off` now cancels every
schedule in the session. Past `at` times, missing timing and invalid
intervals return tool errors.

Verified with an isolated managed-mode server and a scripted
OpenAI-compatible mock model driving `execute`: create every 5s fired
every 5s; `at` fired at 15:55:42.013 for a 15:55:42 target; a one-shot
created before a restart fired on time after it (15:56:23.013); list
returned both schedules with server time; cancel stopped firing; invalid
inputs returned the three expected errors. core and server typecheck.
2026-09-29 11:57:09 -04:00
Ryan Vogel e9f17d5ebe chore(core): format session schedule service
Prettier wrapped the long catchCause line; no behavior change.
2026-09-29 11:51:17 -04:00
Ryan Vogel a5ceab882e feat(core): run session schedules as global background jobs
Moves schedule timers out of the per-Location plugin into a new global
SessionSchedule service. Previously timers lived in the Location's plugin
scope, so they stopped on server restart until something reloaded the
Location, and stopped whenever the Location was evicted after 60 minutes
of inactivity. Timers now live in the global service and call
Session.prompt, which loads the Location on demand, so schedules fire
even when the session is not open.

- SessionSchedule persists entries in KV (session.schedule/<id>) and
  exposes set/remove/resume. A deleted session ends its schedule.
- The managed server calls resume at boot next to restart continuity;
  unmanaged servers sharing the database do not restore schedules.
- The opencode.schedule plugin now only registers /schedule and
  delegates to the service; the per-Location restore logic is removed.

Verified: core and server typecheck; against an isolated managed-mode
server, a 5s schedule fired every 5s, resumed after a restart with no
client touching the session, and `/schedule off` stopped the
boot-restored timer. Server tests: 3 OpenAI OAuth port tests fail
identically without this change.
2026-09-29 11:43:20 -04:00
Ryan Vogel e0709f8de0 feat(core): add /schedule command for recurring session prompts
Adds a minimal built-in plugin (opencode.schedule) that registers
`/schedule <30s|15m|2h> <prompt>` and `/schedule off`. Each schedule is
saved in plugin storage keyed by session ID and re-queues the prompt with
delivery "queue" on a fixed interval, modeled on the warming plugin loop.
When a location loads, the plugin restarts only schedules whose session
belongs to that location, so a session is never double-scheduled.

Known gaps (prototype): schedules resume after a server restart only once
something loads the session's location, and intervals longer than the
60-minute location inactivity TTL stop when the location is evicted.

Verified: core typecheck passes; against an isolated `serve` instance, a
5s schedule admitted prompts every 5s, stopped across a restart, and
resumed after the session's location was reloaded by a manual prompt.
2026-09-29 11:35:53 -04:00
6 changed files with 275 additions and 0 deletions

No files matched your search

+1
View File
@@ -9,6 +9,7 @@ const prefixes = {
question: "que",
part: "prt",
pty: "pty",
schedule: "sch",
tool: "tool",
workspace: "wrk",
} as const
+5
View File
@@ -61,6 +61,7 @@ import { Ripgrep } from "../ripgrep.js"
import { Session } from "../session.js"
import { SessionCompaction } from "../session/compaction.js"
import { SessionInstructions } from "../session/instructions.js"
import { SessionSchedule } from "../session/schedule.js"
import { Shell } from "../shell.js"
import { ShellSelect } from "../shell/select.js"
import { Snapshot } from "../snapshot.js"
@@ -102,6 +103,7 @@ import { OptimizePlugin } from "./optimize.js"
import { VcsGitPlugin } from "./vcs/git.js"
import { VerbosityPlugin } from "./verbosity.js"
import { WarmingPlugin } from "./warming.js"
import { SchedulePlugin } from "./schedule.js"
import { WellKnownPlugin } from "../wellknown/plugin.js"
const services = [
@@ -142,6 +144,7 @@ const services = [
Session.Service,
SessionCompaction.Service,
SessionInstructions.Service,
SessionSchedule.Service,
Shell.Service,
ShellSelect.Service,
Snapshot.Service,
@@ -195,6 +198,7 @@ export const requirements = LayerNode.group([
Session.node,
SessionCompaction.node,
SessionInstructions.node,
SessionSchedule.node,
Shell.node,
ShellSelect.node,
Snapshot.node,
@@ -245,6 +249,7 @@ const pre = [
WebSearchTool.Plugin,
WriteTool.Plugin,
WarmingPlugin.Plugin,
SchedulePlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
const post = [
+136
View File
@@ -0,0 +1,136 @@
export * as SchedulePlugin from "./schedule.js"
import { ToolFailure } from "@opencode/ai"
import { define } from "@opencode/plugin/effect/plugin"
import { Clock, Effect, Schema } from "effect"
import { SessionSchedule } from "../session/schedule.js"
const CreateInput = Schema.Struct({
prompt: Schema.String.check(Schema.isMinLength(1)).annotate({
description:
"Message delivered to this session as a new user message when the schedule fires. Write a complete, self-contained instruction.",
}),
every: Schema.optionalKey(Schema.String).annotate({
description: "Repeat interval such as 30s, 15m, 2h or 1d. Without `at`, the first run is one interval from now.",
}),
at: Schema.optionalKey(Schema.String).annotate({
description:
"ISO 8601 timestamp with a timezone offset, e.g. 2026-09-29T15:00:00-04:00. Alone it schedules a single run; with `every` it sets the first run.",
}),
})
const CancelInput = Schema.Struct({
id: Schema.String.annotate({ description: "Schedule ID returned by schedule_create or schedule_list." }),
})
const View = Schema.Struct({
id: Schema.String,
prompt: Schema.String,
next: Schema.String.annotate({ description: "Next run as an ISO 8601 timestamp." }),
every: Schema.optionalKey(Schema.String).annotate({ description: "Repeat interval; absent for a single run." }),
})
const ListOutput = Schema.Struct({
now: Schema.String.annotate({ description: "Current server time as an ISO 8601 timestamp." }),
schedules: Schema.Array(View),
})
const view = (info: SessionSchedule.Info) => ({
id: info.id,
prompt: info.text,
next: new Date(info.next).toISOString(),
...(info.every === undefined ? {} : { every: `${info.every / 1000}s` }),
})
export const Plugin = define({
id: "opencode.schedule",
effect: Effect.fn(function* (ctx) {
const schedule = yield* SessionSchedule.Service
yield* ctx.command.transform((editor) => {
editor.add({
name: "schedule",
description: "repeat a prompt in this session: <30s|15m|2h> <prompt>, or off",
execute: (input) =>
Effect.gen(function* () {
const text = input.prompt.text.trim()
if (text === "off") {
const existing = yield* schedule.list(input.sessionID)
return yield* Effect.forEach(existing, (info) => schedule.cancel(info.id), { discard: true })
}
const [interval = "", ...rest] = text.split(/\s+/)
const every = SessionSchedule.parseInterval(interval)
const prompt = rest.join(" ")
if (every === undefined || !prompt)
return yield* Effect.fail(new Error("Usage: /schedule <30s|15m|2h> <prompt>, or /schedule off"))
yield* schedule.create({ sessionID: input.sessionID, text: prompt, every })
}),
})
})
yield* ctx.tool
.transform((editor) => {
editor.add({
name: "schedule_create",
description:
"Schedule a prompt to be sent back to the current session later, even if no client has it open. Use `every` for recurring checks (e.g. every 15m), `at` for a single run at an absolute time, or both. Returns the schedule ID.",
input: CreateInput,
output: View,
options: { namespace: "opencode", codemode: true },
execute: (input, context) =>
Effect.gen(function* () {
if (input.every === undefined && input.at === undefined)
return yield* new ToolFailure({ message: "Provide `every`, `at`, or both" })
const every = input.every === undefined ? undefined : SessionSchedule.parseInterval(input.every)
if (input.every !== undefined && every === undefined)
return yield* new ToolFailure({ message: `Invalid interval ${input.every}; use e.g. 30s, 15m, 2h, 1d` })
const at = input.at === undefined ? undefined : Date.parse(input.at)
const now = yield* Clock.currentTimeMillis
if (at !== undefined && Number.isNaN(at))
return yield* new ToolFailure({ message: `Invalid timestamp ${input.at}` })
if (at !== undefined && at <= now)
return yield* new ToolFailure({
message: `${input.at} is in the past; the current time is ${new Date(now).toISOString()}`,
})
const info = yield* schedule.create({ sessionID: context.sessionID, text: input.prompt, at, every })
return {
output: view(info),
content: `Scheduled ${info.id}: next run ${view(info).next}${every ? `, then every ${input.every}` : ""}.`,
}
}),
})
editor.add({
name: "schedule_list",
description: "List the prompts scheduled for the current session, with the current server time.",
input: Schema.Struct({}),
output: ListOutput,
options: { namespace: "opencode", codemode: true },
execute: (_input, context) =>
Effect.gen(function* () {
const infos = yield* schedule.list(context.sessionID)
return {
output: {
now: new Date(yield* Clock.currentTimeMillis).toISOString(),
schedules: infos.map(view),
},
}
}),
})
editor.add({
name: "schedule_cancel",
description: "Cancel a scheduled prompt in the current session.",
input: CancelInput,
output: Schema.Struct({ id: Schema.String }),
options: { namespace: "opencode", codemode: true },
execute: (input, context) =>
Effect.gen(function* () {
const owned = (yield* schedule.list(context.sessionID)).some((info) => info.id === input.id)
if (!owned) return yield* new ToolFailure({ message: `No schedule ${input.id} in this session` })
yield* schedule.cancel(input.id)
return { output: { id: input.id }, content: `Cancelled ${input.id}.` }
}),
})
})
.pipe(Effect.orDie)
}),
})
+128
View File
@@ -0,0 +1,128 @@
export * as SessionSchedule from "./schedule.js"
import { Clock, Context, Duration, Effect, Fiber, Layer, Option, Schema } from "effect"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { Identifier } from "../id/id.js"
import { KV } from "../kv.js"
import { Session } from "../session.js"
import { SessionSchema } from "./schema.js"
export const Info = Schema.Struct({
id: Schema.String,
sessionID: SessionSchema.ID,
text: Schema.String,
/** Next fire time in epoch milliseconds. Persisted so restarts keep the cadence. */
next: Schema.Number,
/** Repeat interval in milliseconds; absent for one-shot schedules. */
every: Schema.optionalKey(Schema.Number),
})
export type Info = typeof Info.Type
const decodeInfo = Schema.decodeUnknownOption(Info)
const prefix = "session.schedule/"
const units = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }
/** Parses an interval such as `30s`, `15m`, `2h` or `1d` into milliseconds. */
export function parseInterval(input: string) {
const match = /^(\d+)([smhd])$/.exec(input.trim())
if (!match) return
const value = Number(match[1]) * units[match[2] as keyof typeof units]
return value > 0 ? value : undefined
}
export interface Interface {
/** Saves a prompt that fires once at `at`, or every `every` ms starting one interval from now. */
readonly create: (input: {
sessionID: SessionSchema.ID
text: string
at?: number
every?: number
}) => Effect.Effect<Info>
readonly cancel: (id: string) => Effect.Effect<void>
readonly list: (sessionID: SessionSchema.ID) => Effect.Effect<readonly Info[]>
/**
* Starts timers for every saved schedule. Timers live in this global service rather than in a
* Location, so they keep firing while the Session's Location is unloaded; each prompt loads it on
* demand. Overdue schedules fire once immediately. Inert until called: the managed server calls it
* once at boot.
*/
readonly resume: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionSchedule") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const kv = yield* KV.Service
const sessions = yield* Session.Service
const scope = yield* Effect.scope
const running = new Map<string, Fiber.Fiber<void>>()
const stop = Effect.fnUntraced(function* (id: string) {
const fiber = running.get(id)
running.delete(id)
if (fiber) yield* Fiber.interrupt(fiber)
})
const start = Effect.fnUntraced(function* (info: Info) {
yield* stop(info.id)
const loop = (current: Info): Effect.Effect<void> =>
Effect.gen(function* () {
const wait = current.next - (yield* Clock.currentTimeMillis)
if (wait > 0) yield* Effect.sleep(Duration.millis(wait))
const exists = yield* sessions
.prompt({ sessionID: current.sessionID, text: current.text, delivery: "queue" })
.pipe(
Effect.as(true),
Effect.catchTag("Session.NotFoundError", () => Effect.succeed(false)),
Effect.catchCause((cause) =>
Effect.logWarning("scheduled prompt failed", { id: current.id, cause }).pipe(Effect.as(true)),
),
)
// One-shot schedules and schedules of deleted Sessions end here.
if (!exists || current.every === undefined) {
running.delete(current.id)
return yield* kv.remove(prefix + current.id)
}
// Skip occurrences missed while the server was down instead of firing them in a burst.
const now = yield* Clock.currentTimeMillis
const next = { ...current, next: Math.max(current.next + current.every, now + current.every) }
yield* kv.set(prefix + current.id, next)
return yield* loop(next)
})
running.set(info.id, yield* Effect.forkIn(loop(info), scope))
})
const all = Effect.gen(function* () {
const saved = yield* kv.scan({ prefix, limit: 1000 })
return saved.entries.flatMap((item) => Option.toArray(decodeInfo(item.value)))
})
return Service.of({
create: Effect.fnUntraced(function* (input) {
const now = yield* Clock.currentTimeMillis
const info: Info = {
id: Identifier.ascending("schedule"),
sessionID: input.sessionID,
text: input.text,
next: input.at ?? now + (input.every ?? 0),
...(input.every === undefined ? {} : { every: input.every }),
}
yield* kv.set(prefix + info.id, info)
yield* start(info)
return info
}),
cancel: Effect.fnUntraced(function* (id) {
yield* stop(id)
yield* kv.remove(prefix + id)
}),
list: (sessionID) => all.pipe(Effect.map((infos) => infos.filter((info) => info.sessionID === sessionID))),
resume: Effect.gen(function* () {
yield* Effect.forEach(yield* all, start, { discard: true })
}),
})
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [KV.node, Session.node] })
+3
View File
@@ -3,6 +3,7 @@ export * as ServerProcess from "./process"
import { NodeHttpServer } from "@effect/platform-node"
import { Bus } from "@opencode/core/bus"
import { SessionRestart } from "@opencode/core/session/execution/restart"
import { SessionSchedule } from "@opencode/core/session/schedule"
import { InstallationEvent } from "@opencode/schema/installation-event"
import { hasPtyConnectTicketURL } from "@opencode/protocol/groups/pty"
import { hasPersistentPtyConnectTicketURL } from "@opencode/protocol/groups/persistent-pty"
@@ -105,6 +106,8 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
yield* installRestartContinuity(Context.get(context, SessionRestart.Service)).pipe(
Effect.provideService(Scope.Scope, applicationScope),
)
// Only the managed server restores saved schedules, so extra servers sharing the database never double-fire.
yield* Context.get(context, SessionSchedule.Service).resume
}
yield* Ref.set(application, Option.some(Context.get(context, HttpRouter.HttpRouter).asHttpEffect()))
yield* status.ready
+2
View File
@@ -27,6 +27,7 @@ import { LocationServiceMap } from "@opencode/core/location-service-map"
import { LocationActivity } from "@opencode/core/location-activity"
import { ModelsDev } from "@opencode/core/models-dev"
import { SessionRestart } from "@opencode/core/session/execution/restart"
import { SessionSchedule } from "@opencode/core/session/schedule"
import { PluginUpdate } from "@opencode/core/plugin/update"
import { SdkPlugins } from "@opencode/core/plugin/sdk"
import { WellKnown } from "@opencode/core/wellknown"
@@ -73,6 +74,7 @@ const applicationServiceNodes = [
LocationServiceMap.node,
LocationActivity.node,
SessionRestart.node,
SessionSchedule.node,
Workspace.node,
] as const
const applicationServices = LayerNode.group(applicationServiceNodes)