Status: Research Complete - Ready for Implementation
Migration Type: Built-in Feature → External Plugin
Target: Separate npm package or local plugin
Feasibility: ✅ CONFIRMED - All blockers resolved
- Executive Summary
- Research Findings
- Blocker Resolutions
- Migration Architecture
- Implementation Plan
- Testing Strategy
- Trade-offs & Limitations
- Reference Implementation
Location: packages/opencode/src/reminder/
Files:
manager.ts(282 lines) - Core manager with timer persistencereminder.ts(23 lines) - Zod schema definitionspackages/opencode/src/tool/reminderadd.ts(60 lines)packages/opencode/src/tool/reminderlist.ts(37 lines)packages/opencode/src/tool/reminderremove.ts(47 lines)packages/opencode/src/tool/*.txt- Tool descriptions
Dependencies:
StorageAPI for persistenceInstance.state()for project-scoped stateBus.subscribe()for event handlingSessionPrompt.prompt()for reminder executionIdentifier.ascending()for ID generationConfig.get()for configurationFlag.OPENCODE_DISABLE_REMINDERSfor feature toggle
Location: External package or .opencode/plugin/reminders.ts
Distribution Options:
- npm package:
opencode-reminders@1.0.0 - Local plugin:
.opencode/plugin/reminders.ts - Global plugin:
~/.config/opencode/plugin/reminders.ts
Why Migrate:
- ✅ Decouple from core OpenCode repository
- ✅ Allow external maintenance and updates
- ✅ Enable user opt-in/opt-out via plugin system
- ✅ Serve as reference implementation for complex plugins
- ✅ Reduce core codebase size
Plugin Input (PluginInput):
export type PluginInput = {
client: ReturnType<typeof createOpencodeClient> // Full SDK client
project: Project // Project metadata
directory: string // Project directory
worktree: string // Git worktree path
$: BunShell // Bun shell for commands
}Plugin Hooks (Hooks):
export interface Hooks {
event?: (input: { event: Event }) => Promise<void>
config?: (input: Config) => Promise<void>
tool?: { [key: string]: ToolDefinition }
"chat.message"?: (input: {}, output: { message: UserMessage; parts: Part[] }) => Promise<void>
"chat.params"?: (input: {...}, output: {...}) => Promise<void>
"permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise<void>
"tool.execute.before"?: (input: {...}, output: {...}) => Promise<void>
"tool.execute.after"?: (input: {...}, output: {...}) => Promise<void>
}SDK Client Methods (Relevant):
client.session.prompt({ path: { id }, body: { parts, model? } })
client.session.get({ path: { id } })
client.session.list()
client.event.subscribe() // SSE stream
client.config.get()Event Types (Available):
type Event =
| EventSessionDeleted // ← Critical for cleanup!
| EventSessionUpdated
| EventMessageUpdated
| EventPermissionUpdated
| EventFileEdited
| EventTodoUpdated
| EventSessionIdle
| EventSessionCompacted
| EventSessionError
// ... moreInternal APIs (No SDK Equivalent):
StorageAPI (Storage.write(),Storage.read(),Storage.list())Instance.state()for automatic state managementBus.subscribe()for internal event busIdentifier.ascending()for ID generationPermission.RejectedErrorspecific error typeLogservice for structured logging
Problem: SDK does not expose Storage API used by current implementation
Current Code:
// packages/opencode/src/reminder/manager.ts
import { Storage } from "../storage/storage"
await Storage.write(["reminder", Instance.project.id, reminder.id], reminder)
await Storage.read<Reminder.Info>(key)
await Storage.remove(key)
await Storage.list(["reminder", Instance.project.id])Resolution: Use filesystem storage via Bun APIs
Implementation:
// Plugin approach
const storageDir = path.join(ctx.directory, ".opencode-reminders", ctx.project.id)
// Write
await Bun.write(path.join(storageDir, `${reminder.id}.json`), JSON.stringify(reminder, null, 2))
// Read
const reminder = await Bun.file(path.join(storageDir, `${reminderID}.json`)).json()
// List
const files = await Array.fromAsync(new Bun.Glob("*.json").scan({ cwd: storageDir, absolute: true }))
// Remove
await Bun.$`rm -f ${path.join(storageDir, `${reminderID}.json`)}`.quiet()
// or
await fs.unlink(path.join(storageDir, `${reminderID}.json`))Storage Structure:
.opencode-reminders/
prj_abc123/ # Project ID
rem_xyz789.json # Reminder file
rem_abc456.json
prj_def456/
rem_ghi789.json
Advantages:
- ✅ Simple JSON files (human-readable)
- ✅ Easy to debug and inspect
- ✅ No dependency on internal Storage API
- ✅ Can use
.gitignoreto exclude from version control
Considerations:
⚠️ Not integrated with OpenCode's storage migration system⚠️ Plugin responsible for data format versioning⚠️ Need to handle concurrent writes (use locks if needed)
Problem: Need to send reminder prompts without specifying model
Current Code:
// packages/opencode/src/reminder/manager.ts
import { SessionPrompt } from "../session/prompt"
await SessionPrompt.prompt({
sessionID: reminder.sessionID,
messageID: Identifier.ascending("message"),
parts: [
{
id: Identifier.ascending("part"),
type: "text",
text: reminder.originalPrompt,
},
],
})Resolution: ✅ Model parameter is OPTIONAL in SDK
SDK Type Definition:
// packages/sdk/js/src/gen/types.gen.ts
export type SessionPromptData = {
body?: {
messageID?: string
model?: {
// ← OPTIONAL!
providerID: string
modelID: string
}
agent?: string
parts: Array<TextPartInput | FilePartInput | AgentPartInput>
}
path: { id: string }
}Implementation:
// Plugin approach - omit model parameter
await client.session.prompt({
path: { id: reminder.sessionID },
body: {
parts: [
{
type: "text",
text: reminder.originalPrompt,
},
],
},
})Behavior:
- ✅ Session will use its current model configuration
- ✅ No need to track or store model info in reminders
- ✅ Respects user's current session settings
messageID Handling:
- SDK auto-generates messageID if not provided
- Plugin can omit
messageIDfield entirely - If needed, use custom ID:
messageID: crypto.randomUUID()
Problem: Need to listen for session deletion to clean up reminders
Current Code:
// packages/opencode/src/reminder/manager.ts
import { Bus } from "../bus"
import { Session } from "../session"
Bus.subscribe(Session.Event.Deleted, async ({ properties }) => {
await cleanupSession(properties.info.id)
})Resolution: ✅ EventSessionDeleted is available via plugin event hook
SDK Event Type:
// packages/sdk/js/src/gen/types.gen.ts:1174
export type EventSessionDeleted = {
type: "session.deleted"
properties: {
info: Session
}
}
export type Session = {
id: string
projectID: string
directory: string
parentID?: string
title: string
// ... more fields
}Implementation:
// Plugin approach
export const RemindersPlugin: Plugin = async (ctx) => {
const state = { reminders: new Map(), timers: new Map() }
return {
async event({ event }) {
// Filter for session deletion events
if (event.type === "session.deleted") {
const sessionID = event.properties.info.id
// Clean up all reminders for this session
const reminders = Array.from(state.reminders.values()).filter((r) => r.sessionID === sessionID)
for (const reminder of reminders) {
await cancelReminder(reminder.id, state)
}
}
},
}
}Available Events for Future Use:
session.updated- Track session changessession.idle- Pause reminders when idle?session.compacted- Verify reminder messages weren't lostmessage.updated- Track reminder execution status
Problem: Current implementation uses Instance.state() for automatic project-scoped state
Current Code:
// packages/opencode/src/reminder/manager.ts
import { Instance } from "../project/instance"
const state = Instance.state(
() => ({
reminders: new Map<string, Reminder.Info>(),
timers: new Map<string, NodeJS.Timeout>(),
}),
async (state) => {
// Cleanup all timers on disposal
for (const timer of state.timers.values()) {
clearTimeout(timer)
}
},
)Resolution: Plugins are initialized per-project, use closure-based state
How Plugins Initialize:
// From packages/opencode/src/plugin/index.ts:14
const state = Instance.state(async () => {
const input: PluginInput = {
client,
project: Instance.project, // ← Project-specific!
worktree: Instance.worktree,
directory: Instance.directory,
$: Bun.$,
}
for (let plugin of plugins) {
const mod = await import(plugin)
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
const init = await fn(input) // ← Called once per project
hooks.push(init)
}
}
})Implementation:
// Plugin approach - state in closure
export const RemindersPlugin: Plugin = async (ctx) => {
// This state is scoped to THIS plugin instance
// Each project gets its own instance
const state = {
reminders: new Map<string, Reminder>(),
timers: new Map<string, NodeJS.Timeout>(),
projectID: ctx.project.id,
}
// Initialize state from storage
await restoreFromStorage(ctx, state)
// Return hooks that close over state
return {
async event({ event }) {
// Uses state from closure
},
tool: {
reminderadd: tool({
async execute(args, context) {
// Uses state from closure
},
}),
},
}
}Cleanup Handling:
Since there's no automatic disposal hook, handle cleanup via:
Option 1: Process exit handlers
export const RemindersPlugin: Plugin = async (ctx) => {
const state = {
/* ... */
}
// Clean up on process exit
process.on("beforeExit", () => {
for (const timer of state.timers.values()) {
clearTimeout(timer)
}
})
return {
/* ... */
}
}Option 2: Event-based cleanup
// Clean up when project changes
async event({ event }) {
if (event.type === "project.changed") {
for (const timer of state.timers.values()) {
clearTimeout(timer)
}
}
}Note: In practice, timers will be cleaned up when Node.js process exits, so explicit cleanup is optional but recommended for clean shutdown.
Problem: Current implementation uses internal Identifier.ascending() API
Current Code:
// packages/opencode/src/reminder/manager.ts
import { Identifier } from "../id/id"
const reminder: Reminder.Info = {
id: Identifier.ascending("reminder"), // Generates: "rem_<timestamp>_<counter>"
// ...
}Resolution: Use standard UUID or custom ID scheme
Implementation Options:
Option 1: UUID (Recommended)
// Built-in Node.js crypto
const reminderID = crypto.randomUUID()
// Example: "550e8400-e29b-41d4-a716-446655440000"Option 2: NanoID
// Requires: import { nanoid } from 'nanoid'
const reminderID = nanoid()
// Example: "V1StGXR8_Z5jdHi6B-myT"Option 3: Custom Ascending IDs (Compatible with original)
// Mimic Identifier.ascending() behavior
let counter = 0
function generateID(prefix: string): string {
return `${prefix}_${Date.now()}_${counter++}`
}
const reminderID = generateID("rem")
// Example: "rem_1730970123456_0"Recommendation: Use crypto.randomUUID() for simplicity
- ✅ No external dependencies
- ✅ Guaranteed uniqueness
- ✅ Standard format
- ✅ Built into Node.js/Bun
For Part IDs:
// Original used Identifier.ascending("part")
// Plugin equivalent:
const partID = crypto.randomUUID()
// Or if you need the "prt_" prefix:
const partID = `prt_${crypto.randomUUID()}`Problem: Internal API has specific Permission.RejectedError type
Current Code:
// packages/opencode/src/reminder/manager.ts
import { Permission } from "../permission"
try {
await SessionPrompt.prompt({ ... })
} catch (error) {
if (error instanceof Permission.RejectedError) {
// Handle permission denial
if (reminder.type === "recurring") {
// Reschedule
} else {
// Cancel
}
}
}Resolution: SDK uses different error types, check error names
SDK Error Types:
// packages/sdk/js/src/gen/types.gen.ts
export type ProviderAuthError = {
name: "ProviderAuthError"
data: { providerID: string; message: string }
}
export type MessageAbortedError = {
name: "MessageAbortedError"
data: { message: string }
}
export type UnknownError = {
name: "UnknownError"
data: { message: string }
}
// Session errors can be:
type SessionPromptError = BadRequestError | NotFoundErrorImplementation:
// Plugin approach
async function executeReminder(reminder: Reminder, ctx: PluginInput, state: State) {
try {
await ctx.client.session.prompt({
path: { id: reminder.sessionID },
body: { parts: [{ type: "text", text: reminder.originalPrompt }] },
})
reminder.time.lastExecution = Date.now()
await saveReminder(reminder, ctx)
if (reminder.type === "recurring") {
reminder.time.nextExecution = Date.now() + reminder.interval
await scheduleTimer(reminder, ctx, state)
} else {
await cancelReminder(reminder.id, ctx, state)
}
} catch (error: any) {
console.error(`Reminder ${reminder.id} execution failed:`, error)
// Check error type
if (error?.name === "MessageAbortedError") {
// User aborted - possibly permission denial
if (reminder.type === "recurring") {
// Reschedule recurring reminders
reminder.time.nextExecution = Date.now() + reminder.interval
await saveReminder(reminder, ctx)
await scheduleTimer(reminder, ctx, state)
} else {
// Cancel one-time reminders
await cancelReminder(reminder.id, ctx, state)
}
} else if (error?.name === "NotFoundError") {
// Session deleted - clean up
await cancelReminder(reminder.id, ctx, state)
} else {
// Unknown error - cancel to be safe
await cancelReminder(reminder.id, ctx, state)
}
}
}Error Handling Strategy:
- MessageAbortedError → Likely permission denial, reschedule recurring
- NotFoundError → Session deleted, cancel reminder
- ProviderAuthError → Auth issue, cancel reminder
- UnknownError → Generic error, cancel reminder
- Success → Update execution time, schedule next (if recurring)
Problem: Need to restore reminders from storage on startup
Current Code:
// packages/opencode/src/reminder/manager.ts
export function init() {
log.info("init")
// Restore from storage asynchronously
Storage.list(["reminder", Instance.project.id]).then(async (reminderKeys) => {
const config = await Config.get()
if (config.reminders?.enabled === false) {
return
}
for (const key of reminderKeys) {
const reminder = await Storage.read<Reminder.Info>(key)
// Validate session, schedule timer, etc.
}
})
}
// Called from packages/opencode/src/project/bootstrap.ts
ReminderManager.init()Resolution: Initialize in plugin function, which is called after project bootstrap
Plugin Lifecycle:
// packages/opencode/src/plugin/index.ts
export async function init() {
const hooks = await state().then((x) => x.hooks)
const config = await Config.get()
// Config hook called first
for (const hook of hooks) {
await hook.config?.(config)
}
// Event subscription setup
Bus.subscribeAll(async (input) => {
for (const hook of hooks) {
hook["event"]?.({ event: input })
}
})
}Implementation:
// Plugin approach - restore in plugin function
export const RemindersPlugin: Plugin = async (ctx) => {
const { client, project, directory } = ctx
const storageDir = path.join(directory, ".opencode-reminders", project.id)
const state = {
reminders: new Map<string, Reminder>(),
timers: new Map<string, NodeJS.Timeout>(),
}
// Ensure storage directory exists
await Bun.$`mkdir -p ${storageDir}`.quiet()
// Restore reminders from storage
const gracePeriod = 60 * 60 * 1000 // 1 hour
const now = Date.now()
for await (const file of new Bun.Glob("*.json").scan({ cwd: storageDir, absolute: true })) {
try {
const reminder = (await Bun.file(file).json()) as Reminder
// Validate session still exists
try {
await client.session.get({ path: { id: reminder.sessionID } })
} catch {
// Session deleted, remove reminder
await Bun.$`rm -f ${file}`.quiet()
continue
}
// Check if reminder is not too expired
if (reminder.time.nextExecution + gracePeriod < now) {
// Too old, remove
await Bun.$`rm -f ${file}`.quiet()
continue
}
// Valid reminder - restore
state.reminders.set(reminder.id, reminder)
await scheduleTimer(reminder, ctx, state)
} catch (error) {
console.error(`Failed to restore reminder from ${file}:`, error)
// Optionally delete corrupted file
await Bun.$`rm -f ${file}`.quiet()
}
}
console.log(`Restored ${state.reminders.size} reminders for project ${project.id}`)
return {
// event hook, tools, etc.
}
}Validation Steps During Restore:
- ✅ Read JSON file
- ✅ Verify session exists (via SDK)
- ✅ Check expiration (nextExecution + grace period)
- ✅ Schedule timer
- ✅ Handle corrupted/invalid files
Safe to Run:
- Plugin initialization happens AFTER project bootstrap
- Config is available via
confighook - SDK client is fully initialized
- Can make async calls to validate state
Option 1: npm Package
opencode-reminders/
src/
index.ts # Main plugin export
types.ts # Reminder types
storage.ts # Storage helpers
scheduler.ts # Timer management
tools/
add.ts # reminderadd tool
list.ts # reminderlist tool
remove.ts # reminderremove tool
package.json
tsconfig.json
README.md
Option 2: Local Plugin
.opencode/
plugin/
reminders.ts # All-in-one plugin file
Option 3: Global Plugin
~/.config/opencode/
plugin/
reminders.ts
Reminder Type (Maintained from Original):
type Reminder = {
id: string // crypto.randomUUID()
sessionID: string // From tool context
projectID: string // From PluginInput.project.id
type: "one-time" | "recurring"
interval: number // milliseconds
originalPrompt: string // Resolved action
userDescription: string // Human-readable label
time: {
created: number // Date.now()
nextExecution: number // When to execute
lastExecution?: number // When last executed
}
status: "active" | "paused" | "cancelled"
}Storage Format:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"sessionID": "ses_1234567890_0",
"projectID": "abc123def456",
"type": "recurring",
"interval": 300000,
"originalPrompt": "Check /workspace/status.log for new entries and report changes",
"userDescription": "Check status log every 5 minutes",
"time": {
"created": 1730970000000,
"nextExecution": 1730970300000,
"lastExecution": 1730970000000
},
"status": "active"
}type State = {
reminders: Map<string, Reminder> // In-memory cache
timers: Map<string, NodeJS.Timeout> // Active timers
projectID: string // For validation
}State Operations:
saveReminder()- Write to disk + update maploadReminder()- Read from diskcancelReminder()- Clear timer + delete file + remove from mapscheduleTimer()- Create setTimeout + store in maprestoreFromStorage()- Load all reminders on init
1.1 Create Plugin Package Structure
mkdir -p opencode-reminders/src/tools
cd opencode-reminders
bun init -y1.2 Install Dependencies
{
"name": "opencode-reminders",
"version": "1.0.0",
"type": "module",
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"zod": "^3.22.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
},
"exports": {
".": "./src/index.ts"
}
}1.3 TypeScript Configuration
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}2.1 Define Types (src/types.ts)
import { z } from "zod"
export const ReminderSchema = z.object({
id: z.string(),
sessionID: z.string(),
projectID: z.string(),
type: z.enum(["one-time", "recurring"]),
interval: z.number(),
originalPrompt: z.string(),
userDescription: z.string(),
time: z.object({
created: z.number(),
nextExecution: z.number(),
lastExecution: z.number().optional(),
}),
status: z.enum(["active", "paused", "cancelled"]),
})
export type Reminder = z.infer<typeof ReminderSchema>
export type State = {
reminders: Map<string, Reminder>
timers: Map<string, NodeJS.Timeout>
projectID: string
}2.2 Storage Module (src/storage.ts)
import path from "path"
import type { PluginInput } from "@opencode-ai/plugin"
import type { Reminder } from "./types"
export async function getStorageDir(ctx: PluginInput): Promise<string> {
const dir = path.join(ctx.directory, ".opencode-reminders", ctx.project.id)
await Bun.$`mkdir -p ${dir}`.quiet()
return dir
}
export async function saveReminder(reminder: Reminder, ctx: PluginInput): Promise<void> {
const dir = await getStorageDir(ctx)
const filePath = path.join(dir, `${reminder.id}.json`)
await Bun.write(filePath, JSON.stringify(reminder, null, 2))
}
export async function loadReminder(id: string, ctx: PluginInput): Promise<Reminder | null> {
const dir = await getStorageDir(ctx)
const filePath = path.join(dir, `${id}.json`)
try {
return await Bun.file(filePath).json()
} catch {
return null
}
}
export async function deleteReminder(id: string, ctx: PluginInput): Promise<void> {
const dir = await getStorageDir(ctx)
const filePath = path.join(dir, `${id}.json`)
await Bun.$`rm -f ${filePath}`.quiet()
}
export async function listReminders(ctx: PluginInput): Promise<Reminder[]> {
const dir = await getStorageDir(ctx)
const reminders: Reminder[] = []
for await (const file of new Bun.Glob("*.json").scan({ cwd: dir, absolute: true })) {
try {
const reminder = await Bun.file(file).json()
reminders.push(reminder)
} catch (error) {
console.error(`Failed to load reminder from ${file}:`, error)
}
}
return reminders
}2.3 Scheduler Module (src/scheduler.ts)
import type { PluginInput } from "@opencode-ai/plugin"
import type { Reminder, State } from "./types"
import { saveReminder, deleteReminder } from "./storage"
export async function scheduleTimer(reminder: Reminder, ctx: PluginInput, state: State): Promise<void> {
// Clear existing timer if any
const existingTimer = state.timers.get(reminder.id)
if (existingTimer) {
clearTimeout(existingTimer)
}
const delay = Math.max(0, reminder.time.nextExecution - Date.now())
const timer = setTimeout(async () => {
state.timers.delete(reminder.id)
await executeReminder(reminder, ctx, state)
}, delay)
state.timers.set(reminder.id, timer)
console.log(`Scheduled reminder ${reminder.id} to execute in ${Math.round(delay / 1000)}s`)
}
export async function executeReminder(reminder: Reminder, ctx: PluginInput, state: State): Promise<void> {
console.log(`Executing reminder ${reminder.id}: ${reminder.userDescription}`)
try {
await ctx.client.session.prompt({
path: { id: reminder.sessionID },
body: {
parts: [
{
type: "text",
text: reminder.originalPrompt,
},
],
},
})
// Update last execution time
reminder.time.lastExecution = Date.now()
if (reminder.type === "recurring") {
// Schedule next execution
reminder.time.nextExecution = Date.now() + reminder.interval
await saveReminder(reminder, ctx)
await scheduleTimer(reminder, ctx, state)
console.log(`Recurring reminder ${reminder.id} rescheduled`)
} else {
// Remove one-time reminder
await cancelReminder(reminder.id, ctx, state)
console.log(`One-time reminder ${reminder.id} completed and removed`)
}
} catch (error: any) {
console.error(`Reminder ${reminder.id} execution failed:`, error)
if (error?.name === "MessageAbortedError") {
// Permission denied or user aborted
if (reminder.type === "recurring") {
// Reschedule recurring reminders
reminder.time.nextExecution = Date.now() + reminder.interval
await saveReminder(reminder, ctx)
await scheduleTimer(reminder, ctx, state)
console.log(`Recurring reminder ${reminder.id} rescheduled after abort`)
} else {
// Cancel one-time reminders
await cancelReminder(reminder.id, ctx, state)
console.log(`One-time reminder ${reminder.id} cancelled after abort`)
}
} else {
// Other errors - cancel reminder
await cancelReminder(reminder.id, ctx, state)
console.log(`Reminder ${reminder.id} cancelled due to error`)
}
}
}
export async function cancelReminder(id: string, ctx: PluginInput, state: State): Promise<void> {
// Clear timer
const timer = state.timers.get(id)
if (timer) {
clearTimeout(timer)
state.timers.delete(id)
}
// Remove from state
state.reminders.delete(id)
// Delete from storage
await deleteReminder(id, ctx)
console.log(`Reminder ${id} cancelled`)
}2.4 Tools (src/tools/add.ts, list.ts, remove.ts)
See Reference Implementation section below for complete tool implementations.
2.5 Main Plugin (src/index.ts)
See Reference Implementation section below for complete plugin implementation.
3.1 Unit Tests
- Test storage operations (save, load, delete, list)
- Test scheduler logic (schedule, execute, cancel)
- Test reminder validation
- Test error handling
3.2 Integration Tests
- Test with actual OpenCode instance
- Test reminder persistence across restarts
- Test session deletion cleanup
- Test concurrent reminders
- Test grace period handling
3.3 Manual Testing
# 1. Install plugin locally
cd opencode-reminders
bun link
# 2. In OpenCode project
cd /path/to/test-project
bun link opencode-reminders
# 3. Add to opencode.json
{
"plugin": ["opencode-reminders"]
}
# 4. Test scenarios
# - Set one-time reminder (30s)
# - Set recurring reminder (1min)
# - List reminders
# - Remove reminder
# - Delete session (verify cleanup)
# - Restart OpenCode (verify persistence)4.1 README.md
- Installation instructions
- Usage examples
- Configuration options
- Troubleshooting
4.2 API Documentation
- Tool parameters
- Event handling
- Storage format
- Error codes
4.3 Migration Guide
- For users of built-in feature
- Breaking changes (if any)
- How to migrate data
5.1 Prepare for Publishing
{
"name": "opencode-reminders",
"version": "1.0.0",
"description": "Reminder plugin for OpenCode - schedule actions to run at intervals",
"keywords": ["opencode", "plugin", "reminders", "scheduler"],
"author": "Your Name",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/yourusername/opencode-reminders"
}
}5.2 Publish to npm
npm publish5.3 Update OpenCode Documentation
- Add to plugins list
- Add example to docs
- Update AGENTS.md template
From packages/opencode/test/reminder/:
- ✅
reminder.test.ts- Schema validation (6 tests) - ✅
manager.test.ts- Core manager (447 tests) - ✅
tools.test.ts- Tool integration (461 tests) - ✅
execution.test.ts- Execution flow (316 tests) - ✅
error-handling.test.ts- Error scenarios (315 tests) - ✅
timer-persistence.test.ts- Storage/restoration (136 tests) - ✅
integration.test.ts- End-to-end (103 tests) - ✅
tool-availability.test.ts- Config/flag control (73 tests) ⚠️ flag.test.ts- Flag functionality (79 tests) - May not apply to plugin
Total: 460+ test cases to migrate
Replace Internal APIs:
// Before (built-in)
import { Storage } from "../../src/storage/storage"
import { Instance } from "../../src/project/instance"
// After (plugin)
import { saveReminder, loadReminder } from "../src/storage"
// Mock PluginInput for testsMock SDK Client:
// Mock session.prompt, session.get, etc.
const mockClient = {
session: {
prompt: vi.fn().mockResolvedValue({}),
get: vi.fn().mockResolvedValue({ id: "ses_123" }),
},
}
const mockCtx: PluginInput = {
client: mockClient,
project: { id: "prj_123", worktree: "/test", time: { created: Date.now() } },
directory: "/test/.opencode",
worktree: "/test",
$: Bun.$,
}Critical Path:
- ✅ Create reminder → Saved to disk
- ✅ List reminders → Returns active reminders
- ✅ Execute reminder → Sends prompt to session
- ✅ Recurring reminder → Reschedules after execution
- ✅ One-time reminder → Deletes after execution
- ✅ Remove reminder → Cancels timer and deletes file
- ✅ Session deleted → Cleans up all reminders
- ✅ Restart → Restores reminders from disk
- ✅ Expired reminder → Removed during restore
- ✅ Invalid session → Cleaned up during restore
Edge Cases:
- Concurrent reminder execution
- Permission denial handling
- Corrupted storage files
- Missing storage directory
- Timer precision issues
- Grace period boundary conditions
1. Storage Integration
- ❌ Not part of OpenCode's unified storage system
- ❌ No automatic migration support
- ❌ Separate from other OpenCode data
2. Logging
- ❌ No access to internal
Logservice - ❌ Must use
console.logor custom logging - ❌ Logs not integrated with OpenCode's log aggregation
3. Error Types
- ❌ Cannot detect
Permission.RejectedErrorspecifically - ❌ Must infer permission denials from error names
- ❌ Less precise error handling
4. State Cleanup
- ❌ No automatic disposal on project change
- ❌ Must handle cleanup manually via process exit handlers
- ❌ Potential for orphaned timers (minimal risk)
5. Configuration
- ❌ No
config.reminderssection in OpenCode config - ❌ Plugin-specific config would be in separate section
- ❌ Cannot use
Flag.OPENCODE_DISABLE_REMINDERSdirectly
1. Independence
- ✅ Separate from core codebase
- ✅ Can be versioned independently
- ✅ Updates don't require OpenCode release
2. Distribution
- ✅ Publishable to npm
- ✅ Users can opt-in/opt-out easily
- ✅ Can be forked and customized
3. Maintainability
- ✅ Clear API boundaries
- ✅ Easier to test in isolation
- ✅ No internal API dependencies
4. Example
- ✅ Demonstrates complex plugin patterns
- ✅ Reference for other plugin authors
- ✅ Shows best practices
| Feature | Built-in | Plugin | Notes |
|---|---|---|---|
| Timer persistence | ✅ | ✅ | Via filesystem |
| Session cleanup | ✅ | ✅ | Via event hook |
| Config integration | ✅ | Different config section | |
| Flag support | ✅ | Plugin-level toggle | |
| Error handling | ✅ | Different error types | |
| Logging | ✅ | Console instead of Log service | |
| State management | ✅ | ✅ | Closure-based |
| ID generation | ✅ | ✅ | UUID instead of ascending |
Legend:
- ✅ Fully supported
⚠️ Supported with differences- ❌ Not supported
// src/index.ts
import { Plugin, tool } from "@opencode-ai/plugin"
import { z } from "zod"
import path from "path"
import type { Reminder, State } from "./types"
import { ReminderSchema } from "./types"
import { getStorageDir, saveReminder, loadReminder, deleteReminder, listReminders } from "./storage"
import { scheduleTimer, executeReminder, cancelReminder } from "./scheduler"
export const RemindersPlugin: Plugin = async (ctx) => {
const { client, project, directory } = ctx
console.log(`[RemindersPlugin] Initializing for project ${project.id}`)
// Ensure storage directory exists
const storageDir = await getStorageDir(ctx)
// Initialize state
const state: State = {
reminders: new Map(),
timers: new Map(),
projectID: project.id,
}
// Restore reminders from storage
const gracePeriod = 60 * 60 * 1000 // 1 hour
const now = Date.now()
let restoredCount = 0
let expiredCount = 0
let invalidCount = 0
const storedReminders = await listReminders(ctx)
for (const reminder of storedReminders) {
try {
// Validate schema
ReminderSchema.parse(reminder)
// Validate session exists
try {
await client.session.get({ path: { id: reminder.sessionID } })
} catch {
console.log(
`[RemindersPlugin] Session ${reminder.sessionID} no longer exists, removing reminder ${reminder.id}`,
)
await deleteReminder(reminder.id, ctx)
invalidCount++
continue
}
// Check expiration
if (reminder.time.nextExecution + gracePeriod < now) {
console.log(`[RemindersPlugin] Reminder ${reminder.id} expired, removing`)
await deleteReminder(reminder.id, ctx)
expiredCount++
continue
}
// Valid reminder - restore
state.reminders.set(reminder.id, reminder)
await scheduleTimer(reminder, ctx, state)
restoredCount++
} catch (error) {
console.error(`[RemindersPlugin] Failed to restore reminder:`, error)
// Delete corrupted reminder
if (reminder.id) {
await deleteReminder(reminder.id, ctx)
}
invalidCount++
}
}
console.log(
`[RemindersPlugin] Restored ${restoredCount} reminders (${expiredCount} expired, ${invalidCount} invalid)`,
)
// Cleanup on process exit
process.on("beforeExit", () => {
console.log(`[RemindersPlugin] Cleaning up ${state.timers.size} timers`)
for (const timer of state.timers.values()) {
clearTimeout(timer)
}
})
return {
// Event handling
async event({ event }) {
if (event.type === "session.deleted") {
const sessionID = event.properties.info.id
console.log(`[RemindersPlugin] Session ${sessionID} deleted, cleaning up reminders`)
const remindersToCancel = Array.from(state.reminders.values()).filter((r) => r.sessionID === sessionID)
for (const reminder of remindersToCancel) {
await cancelReminder(reminder.id, ctx, state)
}
console.log(`[RemindersPlugin] Cancelled ${remindersToCancel.length} reminders for session ${sessionID}`)
}
},
// Tools
tool: {
reminderadd: tool({
description: `Set up a reminder to re-execute an action later. Use when user asks to 'remind me to...' or 'check X every Y time'. Actually performs the action when triggered, not just notifies.
Parameters:
- interval_seconds - Time between executions (minimum 30 seconds)
- type - Either "one-time" or "recurring"
- action_prompt - The action to perform when triggered (fully resolved with absolute paths)
- description - Human-readable label for identifying this reminder
User Pattern Recognition:
- "in 5 minutes do X" → one-time, 5min delay
- "every hour do Y" → recurring, 1hr interval
- "regularly check Z" → recurring, 1min default interval
CRITICAL - Action Prompt Requirements:
- Must contain fully resolved information (absolute paths, specific names, concrete data)
- Context may change over time, so avoid vague references
- Include all necessary details for standalone execution
Examples:
- "Wait for 5 min and check this file again for instructions" → Creates one-time reminder
- "Check this website regularly and let me know when it has new information" → Sets recurring 1-minute timer
- "Check my email every hour and reply that I'm busy" → Creates recurring 1-hour timer`,
args: {
interval_seconds: z.number().min(30).describe("Time interval in seconds (minimum 30)"),
type: z.enum(["one-time", "recurring"]).describe("Whether this reminder runs once or repeatedly"),
action_prompt: z.string().describe("Fully resolved action with absolute paths and specific identifiers"),
description: z.string().describe("Human-readable description for identification"),
},
async execute(args, context) {
// Check max reminders limit (default 50)
const maxReminders = 50
const existingCount = Array.from(state.reminders.values()).filter(
(r) => r.sessionID === context.sessionID,
).length
if (existingCount >= maxReminders) {
const reminders = Array.from(state.reminders.values()).filter((r) => r.sessionID === context.sessionID)
return `Can't set more reminders, too many reminders already active (${existingCount}/${maxReminders}). Current reminders:\n${reminders.map((r) => `- ${r.userDescription}`).join("\n")}`
}
// Create reminder
const reminder: Reminder = {
id: crypto.randomUUID(),
sessionID: context.sessionID,
projectID: project.id,
type: args.type,
interval: args.interval_seconds * 1000,
originalPrompt: args.action_prompt,
userDescription: args.description,
time: {
created: Date.now(),
nextExecution: Date.now() + args.interval_seconds * 1000,
},
status: "active",
}
// Save and schedule
state.reminders.set(reminder.id, reminder)
await saveReminder(reminder, ctx)
await scheduleTimer(reminder, ctx, state)
console.log(`[RemindersPlugin] Created ${args.type} reminder ${reminder.id}: ${args.description}`)
return `Reminder set: ${args.description} (${args.type === "one-time" ? "in" : "every"} ${args.interval_seconds} seconds)`
},
}),
reminderlist: tool({
description: `List all active reminders in this session. Use when user asks 'what reminders do I have' or wants to see scheduled actions.
Returns:
- Array of active reminders with descriptions
- Next execution time for each reminder
- Reminder type (one-time or recurring)
Example Usage: "Show me what I'm waiting for"`,
args: {},
async execute(args, context) {
const reminders = Array.from(state.reminders.values()).filter(
(r) => r.sessionID === context.sessionID && r.status === "active",
)
if (reminders.length === 0) {
return "No active reminders in this session."
}
const output = reminders
.map((r) => {
const nextIn = Math.round((r.time.nextExecution - Date.now()) / 1000)
const nextText = nextIn > 0 ? `in ${nextIn}s` : "overdue"
return `- ${r.userDescription} (${r.type}, next execution ${nextText})`
})
.join("\n")
return `Active reminders:\n${output}`
},
}),
reminderremove: tool({
description: `Cancel a scheduled reminder. Use when user asks to 'stop checking X' or 'cancel the reminder for Y'. Matches user's description pattern to existing reminders.
Parameters:
- description_pattern - Text pattern to match against reminder descriptions
Example Usage: "Stop checking my email"
Response Format:
- Success: "Reminder cancelled: No longer checking your email every hour"
- Error: "No matching reminder found" if pattern doesn't match any active reminders
Usage notes:
- Pattern matching is flexible and attempts to find best match
- Use reminderlist first to see available reminders if uncertain
- Only removes reminders from current session`,
args: {
description_pattern: z
.string()
.describe("What the user wants to stop (will match against reminder descriptions)"),
},
async execute(args, context) {
const reminders = Array.from(state.reminders.values()).filter(
(r) => r.sessionID === context.sessionID && r.status === "active",
)
const pattern = args.description_pattern.toLowerCase()
const matches = reminders.filter(
(r) =>
r.userDescription.toLowerCase().includes(pattern) || r.originalPrompt.toLowerCase().includes(pattern),
)
if (matches.length === 0) {
const activeList = reminders.map((r) => `- ${r.userDescription}`).join("\n") || "None"
return `No matching reminder found for "${args.description_pattern}". Active reminders:\n${activeList}`
}
if (matches.length > 1) {
const matchList = matches.map((r) => `- ${r.userDescription}`).join("\n")
return `Multiple reminders match "${args.description_pattern}":\n${matchList}\nPlease be more specific.`
}
const reminder = matches[0]
await cancelReminder(reminder.id, ctx, state)
console.log(`[RemindersPlugin] Cancelled reminder ${reminder.id} via user request`)
return `Reminder cancelled: ${reminder.userDescription}`
},
}),
},
}
}
// Default export for easy import
export default RemindersPlugin# OpenCode Reminders Plugin
Schedule actions to run at intervals in OpenCode sessions. Set one-time or recurring reminders that execute prompts automatically.
## Features
- ⏰ **One-time reminders** - Execute an action after a delay
- 🔄 **Recurring reminders** - Execute an action at regular intervals
- 💾 **Persistent** - Survives OpenCode restarts
- 🧹 **Auto-cleanup** - Removes reminders when sessions are deleted
- 🔒 **Session-scoped** - Reminders tied to specific sessions
## Installation
### npm Package
```bash
npm install opencode-reminders
```
Add to your `opencode.json`:
```json
{
"plugin": ["opencode-reminders"]
}
```
### Local Plugin
Copy `reminders.ts` to `.opencode/plugin/reminders.ts` in your project.
## Usage
### Set a Reminder
**One-time (execute once after delay):**
```
"In 5 minutes, check the build status"
```
**Recurring (execute repeatedly):**
```
"Every hour, check for new emails and summarize them"
```
### List Reminders
```
"What reminders do I have?"
"Show my active reminders"
```
### Remove a Reminder
```
"Stop checking my email"
"Cancel the build status reminder"
```
## How It Works
1. **Storage** - Reminders saved as JSON files in `.opencode-reminders/`
2. **Scheduling** - Uses `setTimeout` to schedule executions
3. **Execution** - Sends prompt to session when timer fires
4. **Cleanup** - Removes reminders when session deleted
## Configuration
Currently no configuration options. Future versions may add:
- Max reminders per project
- Min interval between executions
- Default reminder type
## Data Storage
Reminders stored in:
```
.opencode-reminders/
<project-id>/
<reminder-id>.json
```
Add to `.gitignore`:
```
.opencode-reminders/
```
## Troubleshooting
**Reminders not executing?**
- Check session still exists
- Verify reminder shows in list
- Check console for errors
**Reminders lost after restart?**
- Ensure storage directory not deleted
- Check file permissions
**Too many reminders?**
- Default limit: 50 per session
- Remove old reminders before adding new ones
## Development
```bash
# Install dependencies
bun install
# Build
bun run build
# Test
bun test
# Link for local development
bun link
```
## License
MIT- ✅ Document created - This migration plan
- ⏭️ Get approval - Review with team/stakeholders
- ⏭️ Create plugin package - Follow Phase 1
- ⏭️ Implement core - Follow Phase 2
- ⏭️ Test thoroughly - Follow Phase 3
- ⏭️ Document & publish - Follow Phase 4-5
- Distribution: npm package or local plugin first?
- Versioning: Start at 1.0.0 or 0.1.0?
- Branding: "opencode-reminders" or different name?
- Maintenance: Who maintains after migration?
- Migration: Support automatic migration from built-in?
- All 460+ tests passing
- Plugin installable via npm
- Documentation complete
- No regressions from built-in version
- Performance equivalent or better
- Example added to OpenCode docs
From Built-in (to migrate):
- ✅
packages/opencode/src/reminder/manager.ts - ✅
packages/opencode/src/reminder/reminder.ts - ✅
packages/opencode/src/tool/reminderadd.ts - ✅
packages/opencode/src/tool/reminderadd.txt - ✅
packages/opencode/src/tool/reminderlist.ts - ✅
packages/opencode/src/tool/reminderlist.txt - ✅
packages/opencode/src/tool/reminderremove.ts - ✅
packages/opencode/src/tool/reminderremove.txt - ✅ All test files in
packages/opencode/test/reminder/
New Plugin Files:
- ⏭️
src/index.ts- Main plugin - ⏭️
src/types.ts- Type definitions - ⏭️
src/storage.ts- Storage operations - ⏭️
src/scheduler.ts- Timer management - ⏭️
package.json- Package metadata - ⏭️
tsconfig.json- TypeScript config - ⏭️
README.md- Documentation - ⏭️
test/- Migrated tests
| Built-in API | Plugin Equivalent | Notes |
|---|---|---|
Storage.write() |
Bun.write() |
Filesystem |
Storage.read() |
Bun.file().json() |
Filesystem |
Storage.list() |
Bun.Glob().scan() |
Filesystem |
Storage.remove() |
Bun.$\rm`` |
Shell command |
Instance.state() |
Closure state | Function scope |
Bus.subscribe() |
Plugin event hook |
Event filtering |
SessionPrompt.prompt() |
client.session.prompt() |
SDK method |
Identifier.ascending() |
crypto.randomUUID() |
Standard UUID |
Config.get() |
Plugin config hook |
Via hook |
Log.info() |
console.log() |
Standard logging |
| Phase | Duration | Deliverable |
|---|---|---|
| Setup | 1-2 hours | Package structure |
| Core | 4-6 hours | Working plugin |
| Testing | 2-3 hours | Passing tests |
| Docs | 1-2 hours | README + API docs |
| Publishing | 1 hour | npm package |
| Total | 9-14 hours | Production-ready plugin |
Document Version: 1.0
Last Updated: 2025-10-17
Status: Ready for Implementation
Next Review: After Phase 1 completion