Complete reference for the JOC hook system plugin
- Overview
- Plugin Architecture
- Hook Types
- Keyword Detection
- State Management
- Context Injection
- Permission Auto-Approval
- Session Restoration
- Creating Custom Plugins
- Best Practices
The JOC plugin (joc-plugin.ts) provides the hook system that enables:
- Keyword detection: Natural language triggers for modes
- State persistence: Cross-session mode tracking
- Context injection: Injecting context into LLM prompts
- Permission auto-approval: Safe commands auto-approved
- Session restoration: Restoring active modes on restart
// .opencode/plugins/joc-plugin.ts
import type { Plugin, Hooks } from "@opencode-ai/plugin"
import type { Event } from "@opencode-ai/sdk"
export const JocPlugin: Plugin = async ({ project, client, directory, worktree }) => {
// Initialize state
initializeJocState(directory)
// Define hooks
const hooks: Hooks = {}
// Hook implementations...
return hooks
}
export default JocPluginhooks.event = async ({ event }) => { /* ... */ }
hooks["tool.execute.before"] = async (input, output) => { /* ... *// }
hooks["tool.execute.after"] = async (input, output) => { /* ... *// }
hooks["chat.message"] = async (input, output) => { /* ... *// }
// ... more hooksCalled when a new session starts.
hooks.event = async ({ event }) => {
switch (event.type) {
case 'session.created': {
const sessionId = event.properties.info.id
// Restore previous session state
const messages = getSessionRestoreMessages(directory, sessionId)
if (messages.length > 0) {
for (const msg of messages) {
queueContextMessage(sessionId, msg)
}
}
break
}
}
}Called when a session ends.
case 'session.deleted': {
const sessionId = event.properties.info.id
// Clean up session context
clearSessionContext(sessionId)
// Clean up session state files
const sessionDir = join(directory, '.opencode', 'state', 'sessions', sessionId)
if (existsSync(sessionDir)) {
// Remove session state files
readdirSync(sessionDir)
.filter(f => f.endsWith('-state.json'))
.forEach(f => unlinkSync(join(sessionDir, f)))
}
break
}Called before a tool is executed.
hooks["tool.execute.before"] = async (input, output) => {
const toolName = input.tool || 'unknown'
const sessionId = input.sessionID
// Update tool statistics
if (sessionId) {
updateToolStats(toolName, sessionId)
}
// Check for active modes
const modeActive = hasActiveMode(directory, sessionId)
const todoStatus = getTodoStatus(directory)
// Generate pre-tool message
const message = generatePreToolMessage(toolName, todoStatus, modeActive)
if (message && sessionId) {
queueContextMessage(sessionId, `<pre-tool-reminder tool="${toolName}">\n${message}\n</pre-tool-reminder>`)
}
// Track skill activation
if (toolName === 'Skill' || toolName === 'skill') {
const skillName = input.args?.skill
if (skillName) {
writeState(directory, 'skill-active', {
active: true,
started_at: new Date().toISOString(),
skill: skillName,
session_id: sessionId
}, sessionId)
}
}
}Pre-Tool Messages:
| Tool | Message |
|---|---|
| TodoWrite | Mark todos in_progress BEFORE starting, completed IMMEDIATELY after finishing |
| Bash | Use parallel execution for independent tasks. Use run_in_background for long operations |
| Edit | Verify changes work after editing. Test functionality before marking complete |
| Write | Verify changes work after editing. Test functionality before marking complete |
| Read | Read multiple files in parallel when possible for faster analysis |
| Grep | Combine searches in parallel when investigating multiple patterns |
| Generic (mode active) | The boulder never stops. Continue until all tasks complete |
Called after a tool execution completes.
hooks["tool.execute.after"] = async (input, output) => {
const toolName = input.tool || 'unknown'
const sessionId = input.sessionID
const toolOutput = output.output || ''
// Update tool count
const toolCount = sessionId ? updateToolStats(toolName, sessionId) : 1
// Detect failures
let message = ''
switch (toolName) {
case 'Bash':
if (detectBashFailure(toolOutput)) {
message = 'Command failed. Please investigate the error and fix before continuing.'
}
break
case 'Edit':
if (detectWriteFailure(toolOutput)) {
message = 'Edit operation failed. Verify file exists and content matches exactly.'
}
break
case 'Write':
if (detectWriteFailure(toolOutput)) {
message = 'Write operation failed. Check file permissions and directory existence.'
}
break
}
if (message && sessionId) {
queueContextMessage(sessionId, `<post-tool-reminder tool="${toolName}">\n${message}\n</post-tool-reminder>`)
}
// Clear skill active state
if (toolName === 'Skill' || toolName === 'skill') {
clearState(directory, 'skill-active', sessionId)
}
}Called for each chat message.
hooks["chat.message"] = async (input, output) => {
const sessionId = input.sessionID
// Get restore messages
const restoreMessages = getSessionRestoreMessages(directory, sessionId)
if (restoreMessages.length > 0) {
for (const msg of restoreMessages) {
queueContextMessage(sessionId, msg)
}
}
// Continuation messages for active modes
const ralphState = readState(directory, 'ralph', sessionId)
if (ralphState?.active && ralphState.prompt) {
queueContextMessage(sessionId, `
<ralph-continuation>
[RALPH LOOP ACTIVE - Iteration ${ralphState.iteration || 1}/${ralphState.max_iterations || 10}]
Original task: ${ralphState.prompt}
Continue until complete. Run /cancel when done.
</ralph-continuation>
`)
}
const ultraworkState = readState(directory, 'ultrawork', sessionId)
if (ultraworkState?.active && ultraworkState.original_prompt) {
queueContextMessage(sessionId, `
<ultrawork-continuation>
[ULTRAWORK MODE ACTIVE]
Original task: ${ultraworkState.original_prompt}
Reinforcement: ${ultraworkState.reinforcement_count || 0}
Continue with maximum parallelism.
</ultrawork-continuation>
`)
}
}Called when a permission is requested.
hooks["permission.ask"] = async (input, output) => {
const permissionType = input.type
const sessionId = input.sessionID
const pattern = input.pattern
if (permissionType === 'bash' && pattern) {
const command = typeof pattern === 'string' ? pattern : pattern.join(' ')
// Define safe patterns
const safePatterns = [
/^git (status|diff|log|branch|show|fetch)/,
/^npm (test|run (test|lint|build|check|typecheck))/,
/^pnpm (test|run (test|lint|build|check|typecheck))/,
/^yarn (test|run (test|lint|build|check|typecheck))/,
/^tsc( |$)/,
/^eslint /,
/^prettier /,
/^cargo (test|check|clippy|build)/,
/^pytest/,
/^python -m pytest/,
/^ls( |$)/,
]
const isSafe = safePatterns.some(p => p.test(command.trim()))
const hasDangerousChars = /[;&|`$()<>\n\r\t\0\\{}[\]*?~!#]/.test(command)
if (isSafe && !hasDangerousChars) {
output.status = 'allow'
if (sessionId) {
queueContextMessage(sessionId, `
<permission-auto-approved type="bash">
Safe command auto-approved: ${command.substring(0, 100)}
</permission-auto-approved>
`)
}
}
}
}Called before a command is executed.
hooks["command.execute.before"] = async (input, output) => {
const command = input.command
const sessionId = input.sessionID
// Mode commands
if (command === 'ralph-loop' || command === 'ulw-loop' || command === 'ultrawork') {
const state = {
active: true,
started_at: new Date().toISOString(),
session_id: sessionId,
project_path: directory
}
writeState(directory, command === 'ralph-loop' ? 'ralph' : 'ultrawork', state, sessionId)
if (sessionId) {
queueContextMessage(sessionId, `
<mode-activated mode="${command}">
${command} mode is now active. Follow the mode's workflow instructions.
</mode-activated>
`)
}
}
// Cancel commands
if (command === 'cancel-ralph' || command === 'stop-continuation') {
clearModeStates(directory, ['ralph', 'autopilot', 'ultrawork', 'ralplan'], sessionId)
if (sessionId) {
queueContextMessage(sessionId, `
<mode-cancelled>
All active modes have been cancelled. You may proceed normally.
</mode-cancelled>
`)
}
}
}Transforms the system prompt before sending to LLM.
hooks["experimental.chat.system.transform"] = async (input, output) => {
const sessionId = input.sessionID
if (!sessionId) return
// Get queued context messages
const messages = consumeContextMessages(sessionId)
if (messages.length === 0) return
// Inject into system prompt
const contextBlock = `
<joc-plugin-context>
${messages.join('\n\n')}
</joc-plugin-context>
`
output.system.push(contextBlock)
}Called when session is compacted (context trimmed).
hooks["experimental.session.compacting"] = async (input, output) => {
const sessionId = input.sessionID
// Preserve ralph state
const ralphState = readState(directory, 'ralph', sessionId)
if (ralphState?.active) {
output.context.push(`
## Ralph Loop State
- Iteration: ${ralphState.iteration || 1}/${ralphState.max_iterations || 10}
- Original Task: ${ralphState.prompt || 'Unknown'}
- Started: ${ralphState.started_at || 'Unknown'}
`)
}
// Preserve ultrawork state
const ultraworkState = readState(directory, 'ultrawork', sessionId)
if (ultraworkState?.active) {
output.context.push(`
## Ultrawork State
- Original Task: ${ultraworkState.original_prompt || 'Unknown'}
- Reinforcement Count: ${ultraworkState.reinforcement_count || 0}
- Started: ${ultraworkState.started_at || 'Unknown'}
`)
}
// Preserve todo status
const todoStatus = getTodoStatus(directory)
if (todoStatus) {
output.context.push(`## Pending Tasks\n${todoStatus}\n`)
}
// Preserve project memory
const projectMemoryPath = join(directory, '.opencode', 'state', 'project-memory.json')
if (existsSync(projectMemoryPath)) {
const memory = readJsonFile(projectMemoryPath)
if (memory) {
const langs = memory.techStack?.languages?.map(l => l.name).join(', ') || 'Unknown'
const notes = memory.customNotes?.map(n => `- ${n.note}`).join('\n') || ''
output.context.push(`
## Project Memory
- Languages: ${langs}
${notes ? `### Custom Notes:\n${notes}` : ''}
`)
}
}
}const KEYWORD_PATTERNS = {
// Cancel
cancel: /\b(cancelomc|stopomc)\b/i,
// Ralph
ralph: /\b(ralph|don't stop|must complete|until done)\b/i,
// Autopilot
autopilot: /\b(autopilot|auto pilot|auto-pilot|autonomous|full auto|fullsend)\b/i,
autopilotBuild: /\b(build|create|make)\s+me\s+(an?\s+)?(app|feature|project|tool|plugin|website|api|server|cli|script|system|service|dashboard|bot|extension)\b/i,
autopilotWant: /\bi\s+want\s+a\s+/i,
// Ultrawork
ultrawork: /\b(ultrawork|ulw|uw)\b/i,
// Ralplan
ralplan: /\b(ralplan)\b/i,
// Deep interview
deepInterview: /\b(deep[\s-]interview|ouroboros)\b/i,
// AI slop cleaner
aiSlopCleaner: /\b(ai[\s-]?slop|anti[\s-]?slop|deslop|de[\s-]?slop)\b/i,
// TDD
tdd: /\b(tdd)\b/i,
tddTestFirst: /\btest\s+first\b/i,
// Code review
codeReview: /\b(code\s+review|review\s+code)\b/i,
// Security review
securityReview: /\b(security\s+review|review\s+security)\b/i,
// Ultrathink
ultrathink: /\b(ultrathink|think hard|think deeply)\b/i,
// Deepsearch
deepsearch: /\b(deepsearch)\b/i,
// Analyze
analyze: /\b(deep[\s-]?analyze|deepanalyze)\b/i,
// New agent
newAgent: /\b(create[\s-]?agent|new[\s-]?agent|agent[\s-]?creator)\b/i,
}function detectKeywords(prompt: string): KeywordMatch[] {
const matches: KeywordMatch[] = []
const cleanPrompt = sanitizeForKeywordDetection(prompt).toLowerCase()
// Check each pattern
for (const [keyword, pattern] of Object.entries(KEYWORD_PATTERNS)) {
if (hasActionableKeyword(cleanPrompt, pattern)) {
matches.push({ name: keyword, args: '' })
}
}
// Resolve conflicts
return resolveConflicts(matches)
}
function resolveConflicts(matches: KeywordMatch[]): KeywordMatch[] {
// Cancel takes highest priority
if (matches.some(m => m.name === 'cancel')) {
return [matches.find(m => m.name === 'cancel')!]
}
// Priority order
const priorityOrder = [
'cancel', 'ralph', 'autopilot', 'ultrawork', 'ralplan',
'deep-interview', 'ai-slop-cleaner', 'tdd', 'code-review',
'security-review', 'ultrathink', 'deepsearch', 'analyze', 'new-agent'
]
// Sort by priority
return matches.sort((a, b) =>
priorityOrder.indexOf(a.name) - priorityOrder.indexOf(b.name)
)
}const MODE_MESSAGES: Record<string, string> = {
ultrathink: `
<think-mode>
**ULTRATHINK MODE ENABLED** - Extended reasoning activated.
You are now in deep thinking mode. Take your time to:
1. Thoroughly analyze the problem from multiple angles
2. Consider edge cases and potential issues
3. Think through the implications of each approach
4. Reason step-by-step before acting
</think-mode>
`,
deepsearch: `
<search-mode>
MAXIMIZE SEARCH EFFORT. Launch multiple background agents IN PARALLEL:
- explore agents (codebase patterns, file structures)
- librarian agents (remote repos, official docs, GitHub examples)
Plus direct tools: Grep, Glob
NEVER stop at first result - be exhaustive.
</search-mode>
`,
// ... more mode messages
}const STATE_DIR = join(directory, '.opencode', 'state')
const SESSIONS_DIR = join(STATE_DIR, 'sessions')const MODE_STATE_FILES = [
'autopilot-state.json',
'ralph-state.json',
'ultrawork-state.json',
'ralplan-state.json',
'team-state.json',
'ultraqa-state.json',
]function readState(directory: string, stateName: string, sessionId?: string): ModeState | null {
const paths = [
getStatePath(directory, stateName, sessionId),
getStatePath(directory, stateName),
]
for (const path of paths) {
const state = readJsonFile<ModeState>(path)
if (state) {
if (sessionId && state.session_id && state.session_id !== sessionId) {
continue
}
return state
}
}
return null
}
function writeState(directory: string, stateName: string, state: ModeState, sessionId?: string): void {
const path = getStatePath(directory, stateName, sessionId)
writeJsonFile(path, state)
}
function clearState(directory: string, stateName: string, sessionId?: string): void {
const paths = [
getStatePath(directory, stateName, sessionId),
getStatePath(directory, stateName),
]
for (const path of paths) {
try {
if (existsSync(path)) {
unlinkSync(path)
}
} catch { /* best-effort cleanup */ }
}
}interface SessionContext {
pendingMessages: string[]
lastUpdated: string
}
const sessionContextCache = new Map<string, SessionContext>()
function queueContextMessage(sessionId: string, message: string): void {
if (!message.trim()) return
const ctx = getSessionContext(sessionId)
ctx.pendingMessages.push(message)
ctx.lastUpdated = new Date().toISOString()
// Prevent unbounded growth
if (ctx.pendingMessages.length > 20) {
ctx.pendingMessages = ctx.pendingMessages.slice(-20)
}
}
function consumeContextMessages(sessionId: string): string[] {
const ctx = sessionContextCache.get(sessionId)
if (!ctx || ctx.pendingMessages.length === 0) return []
const messages = [...ctx.pendingMessages]
ctx.pendingMessages = []
ctx.lastUpdated = new Date().toISOString()
return messages
}Commands matching these patterns are auto-approved:
| Pattern | Example |
|---|---|
git status/diff/log/branch/show/fetch |
git status |
npm test/run test/lint/build/check/typecheck |
npm test |
pnpm test/run ... |
pnpm test |
yarn test/run ... |
yarn test |
tsc |
tsc --noEmit |
eslint |
eslint src/ |
prettier |
prettier --check . |
cargo test/check/clippy/build |
cargo test |
pytest |
pytest tests/ |
python -m pytest |
python -m pytest |
ls |
ls -la |
Commands with these characters are NOT auto-approved:
const hasDangerousChars = /[;&|`$()<>\n\r\t\0\\{}[\]*?~!#]/.test(command)- Session Start: Check for active mode states
- Generate Messages: Create context messages for each active mode
- Queue Messages: Add to session context
- Inject: Messages injected into LLM on next turn
function getSessionRestoreMessages(directory: string, sessionId?: string): string[] {
const messages: string[] = []
// Check ultrawork
const ultraworkState = readState(directory, 'ultrawork', sessionId)
if (ultraworkState?.active) {
messages.push(`
<session-restore>
[ULTRAWORK MODE RESTORED]
You have an active ultrawork session from ${ultraworkState.started_at}.
Original task: ${ultraworkState.original_prompt}
Treat this as prior-session context only. Prioritize the user's newest request.
</session-restore>
`)
}
// Check ralph
const ralphState = readState(directory, 'ralph', sessionId)
if (ralphState?.active) {
messages.push(`
<session-restore>
[RALPH LOOP RESTORED]
You have an active ralph-loop session.
Original task: ${ralphState.prompt || 'Task in progress'}
Iteration: ${ralphState.iteration || 1}/${ralphState.max_iterations || 10}
Treat this as prior-session context only. Prioritize the user's newest request.
</session-restore>
`)
}
// Check todos
const todoFile = join(directory, '.opencode', 'state', 'todos.json')
if (existsSync(todoFile)) {
const todos = readJsonFile<{ todos?: Array<{ status: string }> }>(todoFile)
if (todos?.todos) {
const incompleteCount = todos.todos.filter(t =>
t.status !== 'completed' && t.status !== 'cancelled'
).length
if (incompleteCount > 0) {
messages.push(`
<session-restore>
[PENDING TASKS DETECTED]
You have ${incompleteCount} incomplete tasks from a previous session.
Treat this as prior-session context only. Prioritize the user's newest request.
</session-restore>
`)
}
}
}
return messages
}// .opencode/plugins/my-plugin.ts
import type { Plugin, Hooks } from "@opencode-ai/plugin"
export const MyPlugin: Plugin = async ({ project, client, directory, worktree }) => {
const hooks: Hooks = {}
// Add custom hooks
hooks["tool.execute.before"] = async (input, output) => {
// Your logic here
}
return hooks
}
export default MyPluginAdd to opencode.jsonc:
- Keep hooks fast: Avoid slow operations
- Use async appropriately: Don't block unnecessarily
- Cache frequently: Reduce redundant operations
- Clean up after yourself: Remove state when modes complete
- Use session IDs: Scope state to sessions
- Handle missing state: Graceful degradation
// Good: Graceful error handling
try {
const state = readState(directory, 'ralph', sessionId)
if (state?.active) {
// Process state
}
} catch (error) {
// Log but don't crash
console.error('Failed to read state:', error)
}
// Bad: Let errors propagate
const state = readState(directory, 'ralph', sessionId)
// Might throw, crash plugin- Installation - Plugin installation
- Execution Modes - Mode state details
- State Management - State persistence
- Tools - TypeScript tools
{ "plugin": [ "./plugins/my-plugin.ts" ] }