Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ jobs:

- name: Setup Bun
uses: ./.github/actions/setup-bun
with:
# ci-typecheck.yml's Linux job is the designated Linux cache
# saver (runs on every push, fast, low collision risk) — this job
# only restores, to avoid racing on the same {OS}-bun-{hash} key.
save-cache: false

- name: Configure Git Identity
run: |
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/release-fork.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ jobs:
- name: Setup Bun
if: inputs.platforms == '' || contains(inputs.platforms, matrix.name)
uses: ./.github/actions/setup-bun
with:
# Linux and Windows bun caches are each owned by a single saver
# elsewhere (ci-typecheck.yml linux, ci-test.yml e2e windows) to
# avoid racing on the same {OS}-bun-{hash} key when this manual
# release build runs concurrently with a push-triggered CI run.
# macOS has no other job in the repo, so it's safe to save here.
save-cache: ${{ matrix.name == 'macos' }}

- name: Build CLI
if: inputs.platforms == '' || contains(inputs.platforms, matrix.name)
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ logs/
tsconfig.tsbuildinfo
# opencode runtime-generated local project config (created when running the built binary in a package dir)
**/.opencode/settings.json

# OpenSpec artifacts (local-only, not tracked)
/openspec/
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,11 @@ Built on top of the MIT-licensed [opencode](https://github.com/anomalyco/opencod

### 📌 Stable on `main`

#### Hooks API (22 events × 5 execution types)
#### Hooks API (27 events × 5 execution types)

Full Claude Code hooks protocol compatibility: `command`, `mcp`, `http`, `prompt`, `agent` hook types with 17 hook events including `PreToolUse`, `PostToolUse`, `SessionStart`, `PermissionRequest`, `WorktreeCreate`, and more.
Full Claude Code hooks protocol compatibility: `command`, `mcp`, `http`, `prompt`, `agent` hook types with 27 hook events including `PreToolUse`, `PostToolUse`, `SessionStart`, `PermissionRequest`, `WorktreeCreate`, and more.

See [hooks reference](./packages/opencode/src/session/prompt/hooks-reference.md).
See [hooks reference](./packages/core/src/plugin/skill/configure-hooks.md).

#### Goal Auto-Loop

Expand Down
22 changes: 20 additions & 2 deletions packages/core/src/plugin/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,16 @@ import { Effect } from "effect"
import { AbsolutePath } from "../schema"
import { SkillV2 } from "../skill"
import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" }
import configureHooksContent from "./skill/configure-hooks.md" with { type: "text" }

export const CustomizeOpencodeContent = customizeOpencodeContent
export const ConfigureHooksContent = configureHooksContent

export const CustomizeOpencodeDescription =
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself."

export const ConfigureHooksDescription =
"Use when the user wants to automatically run something on an opencode event — before/after a tool call, on session start/end, on compaction, etc. — or asks about opencode's hooks / hooks.json / event hooks. Covers hooks.json file locations and format, the 27 supported events, and the 5 hook types (command, mcp, http, prompt, agent). Also use to migrate hooks from Claude Code's .claude/settings.json via /import-claude-hooks."

export const Plugin = define({
id: "skill",
Expand All @@ -19,13 +27,23 @@ export const Plugin = define({
type: "embedded",
skill: SkillV2.Info.make({
name: "customize-opencode",
description:
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.",
description: CustomizeOpencodeDescription,
location: AbsolutePath.make("/builtin/customize-opencode.md"),
content: CustomizeOpencodeContent,
}),
}),
)
draft.source(
SkillV2.EmbeddedSource.make({
type: "embedded",
skill: SkillV2.Info.make({
name: "configure-hooks",
description: ConfigureHooksDescription,
location: AbsolutePath.make("/builtin/configure-hooks.md"),
content: ConfigureHooksContent,
}),
}),
)
})
}),
})
123 changes: 123 additions & 0 deletions packages/core/src/plugin/skill/configure-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<!--
Built-in skill. Name and description are registered in code at
packages/opencode/src/skill/index.ts (CONFIGURE_HOOKS_SKILL_DESCRIPTION).
The body below becomes the skill's content.
-->

# Configuring OpenCode hooks

Hooks let you run code (shell command, MCP tool, HTTP call, LLM prompt, or an
autonomous sub-agent) automatically when specific events happen during a
session — before/after a tool call, on session start, on compaction, etc.
Config lives in dedicated `hooks.json` files (NOT `opencode.json`, NOT
`.claude/settings.json` — `.claude/` is never read for hooks).

## Where files live

| Scope | Path | Hot-reloaded? |
| ------- | ------------------------------------ | --------------------------------- |
| Global | `~/.config/opencode/hooks.json` | No — requires restart |
| Project | `.opencode/hooks.json` | Yes — polled every ~2s |
| Worktree| `<worktree>/.opencode/hooks.json` | Yes (when worktree ≠ project dir) |

Layers concat-append (do NOT override by key): global hooks run, then project
hooks are appended after, in file order. A single event can have hooks from
multiple layers all firing.

If you find a `hooks` field left inside `settings.json` or `.claude/`, it is
ignored — point the user at `/import-claude-hooks` to migrate it.

## File format

Top-level keys are event names; each maps to a list of matcher blocks:

```json
{
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "./scripts/check.sh", "timeout": 10 }]
}
],
"SessionStart": [
{
"matcher": "*",
"hooks": [{ "type": "command", "command": "./scripts/welcome.sh" }]
}
]
}
```

`matcher` selects which tool/target the block applies to:

- `"*"` or omitted — matches everything
- `"Bash"` — exact match (case-insensitive)
- `"Bash|Edit|Write"` — pipe-separated list
- any other string — treated as a regex tested against the target

## Events (27 total)

Tool lifecycle: `PreToolUse`, `PostToolUse`, `PostToolUseFailure`
Permission: `PermissionRequest`, `PermissionDenied`
Session lifecycle: `Setup`, `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`
Subagents: `SubagentStart`, `SubagentStop`
Prompt/compaction: `UserPromptSubmit`, `PreCompact`, `PostCompact`
Tasks/goals: `TaskCreated`, `TaskCompleted`, `TeammateIdle`
Other: `Notification`, `Elicitation`, `ElicitationResult`, `ConfigChange`,
`WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`,
`FileChanged`

If you need the exact input/output shape for a specific event, read
`packages/opencode/src/hook/settings.ts` (`HookEvent`, `HookSpecificOutput`) —
this skill is a map, not the full schema.

## Hook types (all 5 implemented)

| `type` | What it does |
| --------- | ----------------------------------------------------------------------------- |
| `command` | Runs a shell command. Event data is piped to stdin as JSON; stdout/exit code drive the result. |
| `mcp` | Invokes an MCP tool, addressed as `mcp__<server>__<tool>`. |
| `http` | POSTs the event envelope to `url`; response body is parsed as JSON. |
| `prompt` | Sends the event to an LLM, constrained to structured JSON output. |
| `agent` | Runs an autonomous sub-agent loop (bash/read_file/list_dir/grep) to react to the event. |

### `command` protocol

- stdin: JSON envelope with event data
- exit code `0`: success, stdout optionally parsed as `HookJSONOutput` JSON
- exit code `2`: **block** — stderr becomes the block reason, shown to the agent
- any other exit code, or timeout: logged as a warning, does NOT abort the flow
- `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PLUGIN_DATA}` expand to the directory the
hook was declared in / its data dir — usable in `command`
- `options` (fork-only field, no CC equivalent): exported as
`CLAUDE_PLUGIN_OPTION_<KEY>` env vars in the subprocess

### Common output fields (`HookJSONOutput`, applies across types)

```json
{
"decision": "approve" | "block",
"reason": "shown when blocking",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow" | "deny" | "ask",
"additionalContext": "text injected into the session"
}
}
```

Not every field applies to every event — `hookSpecificOutput` shape varies per
event (see `HookSpecificOutput` in `settings.ts` for the exact per-event union).

## Applying changes

Global `hooks.json` loads once at startup — **restart required**. Project and
worktree `hooks.json` are polled and take effect within a few seconds without
a restart.

## Migrating from Claude Code

`/import-claude-hooks` reads `~/.claude/settings.json` / `./.claude/settings.json`
/ `.claude/settings.local.json`, walks the user through importing each hook,
and writes approved ones into the right `hooks.json`. Point users here instead
of hand-copying Claude Code hook config.
18 changes: 18 additions & 0 deletions packages/core/src/session/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,24 @@ export const layer = Layer.effectDiscard(
const messageID = event.data.part.messageID
const sessionID = event.data.part.sessionID
const data = partData(event.data.part)
// A part update can race with revert cleanup deleting its parent message
// (e.g. an interrupted stream flushing a step-start after resend). The
// message is gone, so the part is moot — skip instead of dying on the
// foreign key constraint. Warn so genuine ordering bugs remain observable.
const message = yield* db
.select({ id: MessageTable.id })
.from(MessageTable)
.where(eq(MessageTable.id, messageID))
.get()
.pipe(Effect.orDie)
if (!message) {
yield* Effect.logWarning("part update skipped: parent message no longer exists", {
messageID,
partType: event.data.part.type,
sessionID,
})
return
}
const row = yield* db.select().from(PartTable).where(eq(PartTable.id, id)).get().pipe(Effect.orDie)
yield* db
.insert(PartTable)
Expand Down
14 changes: 14 additions & 0 deletions packages/core/test/plugin/skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,18 @@ describe("SkillPlugin.Plugin", () => {
)
}),
)

it.effect("registers the built-in configure-hooks skill", () =>
Effect.gen(function* () {
const skill = yield* SkillV2.Service
yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } }))

expect(yield* skill.list()).toContainEqual(
expect.objectContaining({
name: "configure-hooks",
description: expect.stringContaining("hooks"),
}),
)
}),
)
})
42 changes: 41 additions & 1 deletion packages/core/test/session-projector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { SessionID } from "@opencode-ai/schema/session-id"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
Expand All @@ -19,7 +21,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInput } from "@opencode-ai/core/session/input"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { testEffect } from "./lib/effect"
import { Snapshot } from "@opencode-ai/core/snapshot"

Expand All @@ -43,6 +45,44 @@ const assistantRow = (
}

describe("SessionProjector", () => {
it.effect("skips part updates whose parent message no longer exists", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const events = yield* EventV2.Service
// A revert cleanup can delete a message while an interrupted stream still
// flushes a part update for it — projection must ignore the orphan part
// instead of dying on the foreign key constraint.
yield* events.publish(SessionV1.Event.PartUpdated, {
sessionID: SessionID.make(sessionID),
time: 1,
part: {
type: "step-start",
id: SessionV1.PartID.make("prt_orphan"),
messageID: SessionV1.MessageID.make("msg_missing"),
sessionID: SessionID.make(sessionID),
},
})
expect(yield* db.select().from(PartTable).all().pipe(Effect.orDie)).toEqual([])
}),
)

it.effect("projects staged, cleared, and committed reverts", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
Expand Down
20 changes: 20 additions & 0 deletions packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { MCP } from "../mcp"
import { Skill } from "../skill"
import PROMPT_INITIALIZE from "./template/initialize.txt"
import PROMPT_REVIEW from "./template/review.txt"
import PROMPT_IMPORT_HOOKS from "./template/import-claude-hooks.txt"
import PROMPT_CREATE_HOOK from "./template/create-hook.txt"
import { LegacyEvent } from "@opencode-ai/schema/legacy-event"

type State = {
Expand Down Expand Up @@ -47,6 +49,8 @@ export const Default = {
REVIEW: "review",
GOAL: "goal",
SUBGOAL: "subgoal",
IMPORT_HOOKS: "import-claude-hooks",
CREATE_HOOK: "create-hook",
} as const

export interface Interface {
Expand Down Expand Up @@ -101,6 +105,22 @@ export const layer = Layer.effect(
template: "",
hints: ["$ARGUMENTS"],
}
commands[Default.IMPORT_HOOKS] = {
name: Default.IMPORT_HOOKS,
description: "Import hooks from Claude Code config to OpenCode hooks.json",
source: "command",
template: PROMPT_IMPORT_HOOKS,
subtask: true,
hints: [],
}
commands[Default.CREATE_HOOK] = {
name: Default.CREATE_HOOK,
description: "Create a new hook interactively and write it to hooks.json",
source: "command",
template: PROMPT_CREATE_HOOK,
subtask: true,
hints: [],
}

for (const [name, command] of Object.entries(cfg.command ?? {})) {
commands[name] = {
Expand Down
Loading
Loading