Skip to content

Commit db306c8

Browse files
Zexiclaude
authored andcommitted
feat: per-session scheduled tasks (cron) with AI tool + popover UI
Adds a recurring-task system bound to each session: - `schedule` table + `schedule_run` log (SQLite, cascade-delete with session) - `Schedule.Service` (Effect Layer) owns per-process cron timers via croner; fires a `schedule.triggered` bus event on each tick - `ScheduleRunner` (separate Layer) listens for triggers, checks SessionStatus, and either calls SessionPrompt.prompt() to inject the scheduled message or records a `skipped` run when the session is busy. Keeping the runner separate lets ToolRegistry depend on Schedule.Service without pulling SessionPrompt's full dep graph - Single `schedule` AI tool with action: "create" | "delete" | "list" (no manual UI creation — AI translates natural language to cron) - HTTP API: GET /session/:id/schedule, DELETE /session/:id/schedule/:id - Web UI: SessionScheduleButton (⏰ + count, only when count > 0) placed left of SessionContextUsage in the session header; hover popover lists rows with expression/message/info-tooltip(last/next ran)/delete-on-hover - Scheduled message bubbles get a "⏰ Scheduled" badge via TextPart.metadata.source==="schedule" - Minimum interval enforced at 60s; max 10 schedules per session - Skip behavior when session busy is the only skip reason (no auto-disable) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 524d3fd commit db306c8

24 files changed

Lines changed: 1928 additions & 63 deletions

File tree

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { Button } from "@opencode-ai/ui/button"
2+
import { Icon } from "@opencode-ai/ui/icon"
3+
import { Popover } from "@opencode-ai/ui/popover"
4+
import { Tooltip } from "@opencode-ai/ui/tooltip"
5+
import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"
6+
import { For, Show, createMemo, createSignal } from "solid-js"
7+
import { useGlobalSDK } from "@/context/global-sdk"
8+
import { useLanguage } from "@/context/language"
9+
import { useSessionLayout } from "@/pages/session/session-layout"
10+
11+
type ScheduleInfo = {
12+
id: string
13+
expression: string
14+
message: string
15+
nextRun: number | null
16+
lastRanAt: number | null
17+
lastRunStatus: "ran" | "skipped" | null
18+
}
19+
20+
const SCHEDULE_QUERY_KEY = ["session", "schedules"] as const
21+
22+
function formatRelativeTime(language: ReturnType<typeof useLanguage>, ts: number | null) {
23+
if (ts === null) return null
24+
return new Date(ts).toLocaleString(language.intl())
25+
}
26+
27+
export function SessionScheduleButton() {
28+
const language = useLanguage()
29+
const globalSDK = useGlobalSDK()
30+
const { params } = useSessionLayout()
31+
const queryClient = useQueryClient()
32+
const [shown, setShown] = createSignal(false)
33+
34+
const sessionID = createMemo(() => params.id)
35+
36+
const schedulesQuery = useQuery(() => ({
37+
queryKey: [...SCHEDULE_QUERY_KEY, sessionID()],
38+
enabled: !!sessionID(),
39+
queryFn: async () => {
40+
const id = sessionID()
41+
if (!id) return [] as ScheduleInfo[]
42+
const result = await globalSDK.client.session.schedules({ sessionID: id })
43+
return ((result?.data as ScheduleInfo[] | undefined) ?? []) as ScheduleInfo[]
44+
},
45+
refetchInterval: 10_000,
46+
staleTime: 5_000,
47+
}))
48+
49+
const deleteMutation = useMutation(() => ({
50+
mutationFn: async (scheduleID: string) => {
51+
const id = sessionID()
52+
if (!id) return
53+
await globalSDK.client.session.deleteSchedule({ sessionID: id, scheduleID })
54+
},
55+
onSuccess: () => {
56+
queryClient.invalidateQueries({ queryKey: [...SCHEDULE_QUERY_KEY, sessionID()] })
57+
},
58+
}))
59+
60+
const schedules = createMemo(() => schedulesQuery.data ?? [])
61+
const count = createMemo(() => schedules().length)
62+
63+
return (
64+
<Show when={sessionID() && count() > 0}>
65+
<Popover
66+
open={shown()}
67+
onOpenChange={setShown}
68+
triggerAs={Button}
69+
triggerProps={{
70+
variant: "ghost",
71+
class: "titlebar-icon w-8 h-6 p-0 box-border",
72+
"aria-label": language.t("schedule.button.label", { count: count() }),
73+
style: { scale: 1 },
74+
}}
75+
trigger={
76+
<div class="relative flex items-center justify-center">
77+
<Icon name="clock" size="small" />
78+
<Show when={count() > 1}>
79+
<span class="absolute -top-1 -right-2 min-w-3.5 h-3.5 px-1 rounded-full bg-icon-info-base text-text-invert-strong text-10-strong leading-none flex items-center justify-center">
80+
{count()}
81+
</span>
82+
</Show>
83+
</div>
84+
}
85+
class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-background-strong border border-border-weak-base shadow-lg rounded-xl"
86+
gutter={4}
87+
placement="bottom-end"
88+
>
89+
<Show when={shown()}>
90+
<div class="flex flex-col">
91+
<div class="px-4 py-3 border-b border-border-weak-base">
92+
<div class="text-13-strong text-text-strong">{language.t("schedule.popover.title")}</div>
93+
<div class="text-11-regular text-text-weak mt-0.5">
94+
{language.t("schedule.popover.subtitle", { count: count() })}
95+
</div>
96+
</div>
97+
<div class="max-h-[400px] overflow-y-auto">
98+
<For each={schedules()}>
99+
{(item) => (
100+
<ScheduleRow
101+
item={item}
102+
onDelete={() => deleteMutation.mutate(item.id)}
103+
deleting={deleteMutation.isPending && deleteMutation.variables === item.id}
104+
/>
105+
)}
106+
</For>
107+
</div>
108+
</div>
109+
</Show>
110+
</Popover>
111+
</Show>
112+
)
113+
}
114+
115+
function ScheduleRow(props: { item: ScheduleInfo; onDelete: () => void; deleting: boolean }) {
116+
const language = useLanguage()
117+
const lastRan = createMemo(() => formatRelativeTime(language, props.item.lastRanAt))
118+
const nextRun = createMemo(() => formatRelativeTime(language, props.item.nextRun))
119+
120+
const tooltipContent = () => (
121+
<div class="flex flex-col gap-1 text-11-regular">
122+
<div class="flex items-center gap-2">
123+
<span class="text-text-invert-base">{language.t("schedule.tooltip.last")}</span>
124+
<Show
125+
when={lastRan()}
126+
fallback={<span class="text-text-invert-weak">{language.t("schedule.tooltip.never")}</span>}
127+
>
128+
<span class="text-text-invert-strong">{lastRan()}</span>
129+
<Show when={props.item.lastRunStatus === "skipped"}>
130+
<span class="text-icon-warning-base">{language.t("schedule.tooltip.skipped")}</span>
131+
</Show>
132+
</Show>
133+
</div>
134+
<div class="flex items-center gap-2">
135+
<span class="text-text-invert-base">{language.t("schedule.tooltip.next")}</span>
136+
<Show
137+
when={nextRun()}
138+
fallback={<span class="text-text-invert-weak">{language.t("schedule.tooltip.never")}</span>}
139+
>
140+
<span class="text-text-invert-strong">{nextRun()}</span>
141+
</Show>
142+
</div>
143+
</div>
144+
)
145+
146+
return (
147+
<div class="group flex items-start gap-2 px-4 py-3 border-b border-border-weak-base last:border-b-0 hover:bg-background-base">
148+
<div class="flex-1 min-w-0">
149+
<div class="font-mono text-11-regular text-text-weak mb-0.5">{props.item.expression}</div>
150+
<div class="text-12-regular text-text-base truncate" title={props.item.message}>
151+
{props.item.message}
152+
</div>
153+
</div>
154+
<Tooltip value={tooltipContent()} placement="left">
155+
<Button
156+
type="button"
157+
variant="ghost"
158+
class="size-5 opacity-60 hover:opacity-100"
159+
aria-label={language.t("schedule.row.info")}
160+
>
161+
<Icon name="help" size="small" />
162+
</Button>
163+
</Tooltip>
164+
<Button
165+
type="button"
166+
variant="ghost"
167+
class="size-5 opacity-0 group-hover:opacity-100 transition-opacity"
168+
aria-label={language.t("schedule.row.delete")}
169+
onClick={props.onDelete}
170+
disabled={props.deleting}
171+
>
172+
<Icon name="close" size="small" />
173+
</Button>
174+
</div>
175+
)
176+
}

packages/app/src/i18n/en.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,8 @@ export const dict = {
362362
"dialog.server.inbound.portPlaceholder": "Port",
363363
"dialog.server.inbound.portRequired": "Enter a port",
364364
"dialog.server.inbound.portInvalid": "Enter a port between 1 and 65535",
365-
"dialog.server.inbound.credentialInvalid": "The credentials do not match the running server. Check your username and password.",
365+
"dialog.server.inbound.credentialInvalid":
366+
"The credentials do not match the running server. Check your username and password.",
366367
"server.row.noUsername": "no username",
367368

368369
"dialog.project.edit.title": "Edit project",
@@ -416,6 +417,16 @@ export const dict = {
416417
"context.usage.clickToView": "Click to view context",
417418
"context.usage.view": "View context usage",
418419

420+
"schedule.button.label": "Scheduled tasks ({count})",
421+
"schedule.popover.title": "Scheduled tasks",
422+
"schedule.popover.subtitle": "{count} active",
423+
"schedule.row.info": "Details",
424+
"schedule.row.delete": "Delete schedule",
425+
"schedule.tooltip.last": "Last:",
426+
"schedule.tooltip.next": "Next:",
427+
"schedule.tooltip.never": "—",
428+
"schedule.tooltip.skipped": "(skipped — session busy)",
429+
419430
"language.en": "English",
420431
"language.zh": "简体中文",
421432
"language.zht": "繁體中文",
@@ -481,7 +492,8 @@ export const dict = {
481492
"error.page.title": "Something went wrong",
482493
"error.page.description": "An error occurred while loading the application.",
483494
"error.page.auth.title": "Authentication required",
484-
"error.page.auth.description": "This server requires a password. Open this page from the OpenCode desktop app, or use a URL that includes an auth token.",
495+
"error.page.auth.description":
496+
"This server requires a password. Open this page from the OpenCode desktop app, or use a URL that includes an auth token.",
485497
"error.page.auth.action.home": "Go to home",
486498
"error.page.details.label": "Error Details",
487499
"error.page.action.restart": "Restart",

packages/app/src/i18n/zh.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,16 @@ export const dict = {
413413
"context.usage.clickToView": "点击查看上下文",
414414
"context.usage.view": "查看上下文用量",
415415

416+
"schedule.button.label": "定时任务({count})",
417+
"schedule.popover.title": "定时任务",
418+
"schedule.popover.subtitle": "{count} 个活跃",
419+
"schedule.row.info": "详情",
420+
"schedule.row.delete": "删除定时任务",
421+
"schedule.tooltip.last": "上次:",
422+
"schedule.tooltip.next": "下次:",
423+
"schedule.tooltip.never": "—",
424+
"schedule.tooltip.skipped": "(已跳过——会话繁忙)",
425+
416426
"language.en": "English",
417427
"language.zh": "简体中文",
418428
"language.zht": "繁體中文",

packages/app/src/pages/session/message-timeline.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { getFilename } from "@opencode-ai/core/util/path"
2020
import { Popover as KobaltePopover } from "@kobalte/core/popover"
2121
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
2222
import { SessionContextUsage } from "@/components/session-context-usage"
23+
import { SessionScheduleButton } from "@/components/session-schedule-button"
2324
import { useDialog } from "@opencode-ai/ui/context/dialog"
2425
import { createResizeObserver } from "@solid-primitives/resize-observer"
2526
import { useLanguage } from "@/context/language"
@@ -815,6 +816,7 @@ export function MessageTimeline(props: {
815816
<Show when={sessionID()} keyed>
816817
{(id) => (
817818
<div class="shrink-0 flex items-center gap-3">
819+
<SessionScheduleButton />
818820
<SessionContextUsage placement="bottom" />
819821
<Show when={!parentID()}>
820822
<DropdownMenu
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
CREATE TABLE `schedule` (
2+
`id` text PRIMARY KEY NOT NULL,
3+
`session_id` text NOT NULL,
4+
`expression` text NOT NULL,
5+
`message` text NOT NULL,
6+
`created_at` integer NOT NULL,
7+
FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE cascade
8+
);
9+
--> statement-breakpoint
10+
CREATE INDEX `schedule_session_idx` ON `schedule` (`session_id`);
11+
--> statement-breakpoint
12+
CREATE TABLE `schedule_run` (
13+
`id` text PRIMARY KEY NOT NULL,
14+
`schedule_id` text NOT NULL,
15+
`ran_at` integer NOT NULL,
16+
`status` text NOT NULL,
17+
FOREIGN KEY (`schedule_id`) REFERENCES `schedule`(`id`) ON UPDATE no action ON DELETE cascade
18+
);
19+
--> statement-breakpoint
20+
CREATE INDEX `schedule_run_idx` ON `schedule_run` (`schedule_id`,`ran_at`);

packages/opencode/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@
125125
"bun-pty": "0.4.8",
126126
"chokidar": "4.0.3",
127127
"clipboardy": "4.0.0",
128+
"croner": "9.0.0",
128129
"cross-spawn": "catalog:",
129130
"decimal.js": "10.5.0",
130131
"diff": "catalog:",

packages/opencode/src/effect/app-runtime.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import { Discovery } from "@/skill/discovery"
2323
import { Question } from "@/question"
2424
import { Permission } from "@/permission"
2525
import { Todo } from "@/session/todo"
26+
import { Schedule } from "@/session/schedule"
27+
import { ScheduleRunner } from "@/session/schedule-runner"
2628
import { Session } from "@/session/session"
2729
import { SessionStatus } from "@/session/status"
2830
import { SessionRunState } from "@/session/run-state"
@@ -82,6 +84,7 @@ export const AppLayer = Layer.mergeAll(
8284
Question.defaultLayer,
8385
Permission.defaultLayer,
8486
Todo.defaultLayer,
87+
ScheduleRunner.defaultLayer,
8588
Session.defaultLayer,
8689
SessionStatus.defaultLayer,
8790
BackgroundJob.defaultLayer,

packages/opencode/src/server/routes/instance/httpapi/groups/session.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { SessionRevert } from "@/session/revert"
88
import { SessionStatus } from "@/session/status"
99
import { SessionSummary } from "@/session/summary"
1010
import { Todo } from "@/session/todo"
11+
import { Schedule } from "@/session/schedule"
1112
import { MessageID, PartID, SessionID } from "@/session/schema"
1213
import { Snapshot } from "@/snapshot"
1314
import { Schema, Struct } from "effect"
@@ -77,6 +78,8 @@ export const SessionPaths = {
7778
get: `${root}/:sessionID`,
7879
children: `${root}/:sessionID/children`,
7980
todo: `${root}/:sessionID/todo`,
81+
schedules: `${root}/:sessionID/schedule`,
82+
deleteSchedule: `${root}/:sessionID/schedule/:scheduleID`,
8083
diff: `${root}/:sessionID/diff`,
8184
messages: `${root}/:sessionID/message`,
8285
message: `${root}/:sessionID/message/:messageID`,
@@ -161,6 +164,31 @@ export const SessionApi = HttpApi.make("session")
161164
description: "Retrieve the todo list associated with a specific session, showing tasks and action items.",
162165
}),
163166
),
167+
HttpApiEndpoint.get("schedules", SessionPaths.schedules, {
168+
params: { sessionID: SessionID },
169+
query: WorkspaceRoutingQuery,
170+
success: described(Schema.Array(Schedule.Info), "List of scheduled tasks"),
171+
error: [HttpApiError.BadRequest, ApiNotFoundError],
172+
}).annotateMerge(
173+
OpenApi.annotations({
174+
identifier: "session.schedules",
175+
summary: "List session scheduled tasks",
176+
description:
177+
"Retrieve the scheduled tasks (cron-driven recurring messages) configured for the specified session.",
178+
}),
179+
),
180+
HttpApiEndpoint.delete("deleteSchedule", SessionPaths.deleteSchedule, {
181+
params: { sessionID: SessionID, scheduleID: Schedule.ID },
182+
query: WorkspaceRoutingQuery,
183+
success: described(Schema.Boolean, "Successfully deleted schedule"),
184+
error: [HttpApiError.BadRequest, ApiNotFoundError],
185+
}).annotateMerge(
186+
OpenApi.annotations({
187+
identifier: "session.deleteSchedule",
188+
summary: "Delete session scheduled task",
189+
description: "Remove a single scheduled task from the session by id.",
190+
}),
191+
),
164192
HttpApiEndpoint.get("diff", SessionPaths.diff, {
165193
params: { sessionID: SessionID },
166194
query: DiffQuery,

0 commit comments

Comments
 (0)