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
2 changes: 1 addition & 1 deletion packages/core/src/plugin/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ 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 26 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."
"Use when the user wants opencode to react to a tool or lifecycle event, run a background check, or poll for a result and resume the same idle conversation. Also use for questions about hooks, hooks.json, or migrating Claude Code hooks. Explains the 26 supported events, 5 handler types, matching, configuration scopes, trust, input/output protocols, synchronous decisions, async/asyncRewake continuation, and how to verify execution. Load this skill before creating or changing a lifecycle hook."

export const CreateDagWorkflowDescription =
"Use when the user wants to create, save, or edit a reusable DAG workflow — a named multi-agent graph they can start again later — or asks where workflow specs live, how to make a workflow available in every project, or why a saved workflow name does not resolve. Covers project/global scopes, composable blocks, low-level nodes, and verification. Do not use to run an existing workflow or to design a one-off graph for the current task; the workflow tool handles those."
Expand Down
526 changes: 326 additions & 200 deletions packages/core/src/plugin/skill/configure-hooks.md

Large diffs are not rendered by default.

53 changes: 39 additions & 14 deletions packages/core/src/plugin/skill/customize-opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ already-loaded config until then.
| Scope | Path |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Project config | `./opencode.json`, `./opencode.jsonc`, or `.opencode/opencode.json` (opencode walks up from the cwd to the worktree root) |
| Global config | `~/.config/opencode/opencode.json` or `~/.config/opencode/opencode.jsonc` (NOT `~/.opencode/`) |
| Global config | `~/.config/opencode/opencode.json` or `~/.config/opencode/opencode.jsonc` (NOT `~/.opencode/`) |
| Project agents | `.opencode/agent/<name>.md` or `.opencode/agents/<name>.md` |
| Global agents | `~/.config/opencode/agent(s)/<name>.md` |
| Project commands | `.opencode/command/<name>.md` or `.opencode/commands/<name>.md` |
Expand Down Expand Up @@ -342,19 +342,44 @@ export default (async ({ client, project, directory, $ }) => {
}) satisfies Plugin
```

Hook surface (mutate `output` in place; return `void`):

- `event(input)`: every bus event
- `config(cfg)`: once on init with the merged config
- `chat.message`, `chat.params`, `chat.headers`
- `tool.execute.before`, `tool.execute.after`
- `tool.definition`
- `command.execute.before`
- `shell.env`
- `permission.ask`
- `experimental.chat.messages.transform`, `experimental.chat.system.transform`,
`experimental.session.compacting`, `experimental.compaction.autocontinue`,
`experimental.text.complete`
These are JavaScript plugin callbacks. For lifecycle handlers configured in
`hooks.json` (command, MCP, HTTP, prompt or agent), including background polling
that resumes an idle conversation, load the `configure-hooks` skill. Its event
names, output protocol and `async` / `asyncRewake` fields belong to that system;
they are not fields to add to the plugin callback object below.

Legacy JavaScript plugin callbacks dispatched through `Plugin.trigger` are
awaited sequentially. The `event({ event })` observer is fire-and-forget:
bus delivery does not await it, and callbacks or later events can overlap.
Callbacks with an
`output` argument mutate that object in place and resolve to `void`; returning
a replacement object does not apply it. Their inputs use JavaScript field
names such as `sessionID`, unlike lifecycle command stdin.

| Callback | Capability and output |
| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `event({ event })` | Observe published bus events asynchronously without delaying bus delivery; no decision output or sequential-await guarantee. |
| `config(cfg)` | Mutate the merged configuration during plugin initialization. |
| `dispose()` | Release plugin-owned resources when the plugin instance is disposed; this is not the end of every assistant turn. |
| `chat.message` | Receive session/agent/model/message identity and mutate `output.message` / `output.parts` for an incoming message. |
| `chat.params` | Adjust `temperature`, `topP`, `topK`, `maxOutputTokens`, and provider `options` before a model request. |
| `chat.headers` | Mutate `output.headers` for a model request. |
| `tool.execute.before` | Receive tool/session/call identity and mutate `output.args` before execution. |
| `tool.execute.after` | Receive identity and arguments, then mutate result `title`, `output`, and `metadata`. |
| `tool.definition` | Mutate a tool's model-facing `description` and `parameters`; preserve the object-rooted parameter schema. |
| `command.execute.before` | Mutate prompt `parts` in the LLM command-template path; directly dispatched commands can bypass this path. |
| `shell.env` | Receive `cwd` and optional session/call identity; add or change `output.env` when a shell environment is assembled. |
| `experimental.chat.messages.transform` | Mutate the model-bound `output.messages` array. |
| `experimental.chat.system.transform` | Receive model and optional session identity; mutate `output.system` strings. |
| `experimental.provider.small_model` | Provide `output.model` when small-model selection needs a fallback; a usable configured `small_model` takes precedence and skips this callback. |
| `experimental.session.compacting` | Add `output.context` or replace the compaction prompt with `output.prompt`. |
| `experimental.compaction.autocontinue` | Set `output.enabled` to control the synthetic continuation after successful compaction. |
| `experimental.text.complete` | Mutate `output.text` when an assistant text part completes. |

`permission.ask` remains in the legacy TypeScript interface, but the current
runtime has no dispatch callsite. Do not use it to implement permission
interception; configure permission rules or the supported lifecycle
`PreToolUse` / `PermissionRequest` contracts instead.

Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`,
`auth: { ... }`, `provider: { ... }`.
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export const layer = Layer.effect(
}
commands[Default.CREATE_HOOK] = {
name: Default.CREATE_HOOK,
description: "Create a new hook interactively and write it to hooks.json",
description: "Create lifecycle hooks, including background polling that resumes an idle conversation",
source: "command",
template: PROMPT_CREATE_HOOK,
subtask: true,
Expand Down
87 changes: 62 additions & 25 deletions packages/opencode/src/command/template/create-hook.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ Load the `configure-hooks` skill first. It defines the 26 events, 5 hook types,

## Process

### 1. Gather inputs interactively
### 1. Resolve the requested configuration

Ask the user for each field, one at a time:
Infer every field the user already specified. Ask only for missing information
that materially affects correct behavior; do not re-ask for supplied values or
write authorization already given. Use `configure-hooks` for the full field,
event-input, output-effect, trust, and async contracts.

**Event** — choose from the `configure-hooks` skill's 26-event list. It names all 26 events:

Expand Down Expand Up @@ -44,26 +47,43 @@ Ask the user for each field, one at a time:
- `http` → the endpoint URL
- `prompt` / `agent` → the prompt / goal text

**Matcher** (only for tool-bound events: `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `PermissionDenied`):
**Matcher** — optional. The five tool/permission events match `tool_name`.
Other supported matcher targets are `SessionStart` → `source`, `SessionEnd` →
`reason`, `Setup` → `trigger`, `PreCompact` / `PostCompact` → `trigger`,
`Notification` → `notification_type`, `SubagentStart` / `SubagentStop` →
`agent_type`, and `FileChanged` → basename of `path`.

- Empty / omitted / `*` — matches all tools
- `Bash` — exact tool name
- `Bash|Edit|Write` — pipe-separated list
- Names using letters, digits or underscores — exact match (case-insensitive), optionally pipe-separated
- Other strings — treated as a regex

For non-tool events, omit the matcher entirely.
For events without a matcher target (including `Stop`, `StopFailure`,
`UserPromptSubmit`, task, elicitation, config, worktree, instruction, and cwd
events), any matcher is ignored and every group runs; omit it for clarity.

**Optional fields** — ask if the user wants to set any:
**Optional fields** — apply specified requirements and defaults; ask only when
a choice is necessary. Fields include `timeout` (positive seconds, default
60), `statusMessage` (logged label, not UI progress), `once` (once per loaded
entry/session), and `if` (tool/permission events only; ignored elsewhere).
Command hooks may set `shell` (`bash`/`powershell`) and `inputFormat`
(`opencode` by default, `claude-code` for an unchanged Claude Code stdin
script). HTTP hooks may set `headers` and `allowedEnvVars`; command hooks may
set `options`.

- `timeout` — seconds before the hook is killed (default 60)
- `statusMessage` — short label logged before the hook runs; it does not guarantee visible UI progress
- `inputFormat` (command hooks only) — `"opencode"` (default) or `"claude-code"`. Offer `"claude-code"` only for an unchanged script written for Claude Code's hook stdin. Such scripts expect `Bash`/`Read`/`Grep`-style tool names and `file_path`-style keys. See the `configure-hooks` skill for the contract.
For background work that should return completion feedback, set both
`async: true` and `asyncRewake: true`. The running handler must finish before
the same session can be rewoken; it is not an in-progress notification. Use a
finite timeout (default 60 seconds) and make Stop hooks honor
`stop_hook_active` and be idempotent. `asyncRewake` alone has no effect; see
`configure-hooks` for which outputs cause a rewake and its limits.

**Scope** — `project` or `global`:
**Scope** — `project`, `global`, or `worktree`:

- `project` → `.opencode/hooks.json` in the current project (hot-reloads in ~2s)
- `global` → `~/.config/opencode/hooks.json` (hot-reloads in ~2s as well)
- `global` → `${Global.Path.config}/hooks.json` (hot-reloads in ~2s as well; follows the XDG config directory, commonly `$XDG_CONFIG_HOME/opencode/hooks.json` and usually `~/.config/opencode/hooks.json`)
- `worktree` → `<worktree>/.opencode/hooks.json` when the worktree is separate

### 2. Build the JSON entry

Expand All @@ -82,7 +102,7 @@ Build the entry in `hooks.json` format. Each event key maps to an array of match
}
```

For non-tool events, omit the `matcher` field:
For an event without a matcher target, omit the `matcher` field:

```json
{
Expand All @@ -96,20 +116,29 @@ For non-tool events, omit the `matcher` field:
}
```

### 3. Confirm with the user
If `requireTrust` / `allowUntrusted` matters, it is a top-level setting, not a
hook-entry field. Follow the trust rules in `configure-hooks`; project and
worktree files cannot opt themselves out of a trust gate.

Show the full JSON entry, including the event name, matcher block, and hook object. Ask for confirmation before writing. If the user requests changes, edit and show it again.
### 3. Confirm with the user when needed

Show the exact JSON addition and destination before writing when the user has
not already authorized the write. If they clearly asked you to create and
write the hook, proceed without asking them to confirm that action again. If
they request changes, incorporate them and ask only about genuinely missing
information.

### 4. Write to hooks.json (merge-append)

Read the target `hooks.json` first:

- **Project scope**: `${PROJECT_DIR}/.opencode/hooks.json`
- **Global scope**: `~/.config/opencode/hooks.json`
- **Global scope**: `${Global.Path.config}/hooks.json` (resolve using the XDG config directory)
- **Worktree scope**: `${WORKTREE_DIR}/.opencode/hooks.json` (only when the active worktree differs from the project directory)

Then:

1. If the file does not exist, create it (create the `.opencode/` directory for project scope if needed).
1. If the file does not exist, create it (create the relevant `.opencode/` directory for project or worktree scope if needed).
2. If the file exists, parse it and **merge-append** the new entry:
- If the event key already exists, append the new matcher block to the existing array (do NOT replace existing entries).
- If the event key does not exist, add it with the new matcher block as the only element.
Expand All @@ -120,38 +149,46 @@ Never overwrite the entire file. Never delete existing hooks.

### 5. Verify the write

After writing, re-read the file and JSON-parse it to confirm validity:
After writing, re-read the file and JSON-parse it to confirm validity. If the
write produced invalid JSON, repair the file while preserving existing entries,
then re-read and parse again. Do not leave your own malformed write for the
user to repair manually. If pre-existing corruption prevents a safe merge,
describe the exact issue and preserve the existing content.

- If the parse succeeds, report success.
- If the parse fails, report the error and ask the user to check the file manually.
- If the parse fails after repair, do not report success; explain what pre-existing or external corruption prevented a safe repair.

### 6. Report hot-reload semantics

- **Project scope**: "The hook will hot-reload within ~2 seconds — it will be active on the next session turn."
- **Global scope**: "The global hooks.json is polled every ~2 seconds too — the hook becomes active shortly after the write, no restart needed."
- **Global scope**: "The global hooks.json is polled every ~2 seconds too — the hook becomes active shortly after the write, no restart needed." Resolve it from `${Global.Path.config}`, which follows the XDG config directory.
- **Worktree scope**: the worktree file is polled too and becomes active shortly after the write.

Point the user at the **Active Hooks** block in the system prompt (visible on the next turn for project-scoped hooks) to confirm the hook is live.
After the final write, allow at least the 2-second poll interval plus the
500-millisecond debounce before ending this command flow. Then, when available,
inspect the **Active Hooks** block to confirm the configuration was loaded.
That block does not prove the hook's behavior works; trigger it and inspect its
input/output when behavior verification is requested.

## Example session

```
User: /create-hook
Agent: Which event should this hook fire on? (see the configure-hooks skill for all 26 events)
Agent: Which event should this hook fire on? (asked only because it was not specified)
User: PreToolUse
Agent: What type of hook? command / mcp / http / prompt / agent
User: command
Agent: What shell command should run?
User: ./scripts/check.sh
Agent: Matcher (which tools should trigger this)? Empty = all tools.
Agent: Which tools should trigger this? (asked only if relevant and not specified)
User: Bash
Agent: Project or global scope? (project hot-reloads in ~2s; global hot-reloads in ~2s too)
Agent: Project or global scope? (asked only because scope was not specified)
User: project

Here is the entry I will write to .opencode/hooks.json:
{ "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "./scripts/check.sh" }] }] }
Proceed? (y/n)
User: y
The write proceeds under the user's existing authorization.

✓ Written to .opencode/hooks.json (merge-appended alongside existing hooks).
The hook will hot-reload within ~2 seconds — it will be active on the next session turn.
It will hot-reload shortly; the Active Hooks block confirms loaded configuration, not that behavior has been verified.
```
Loading
Loading