diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 65c3c1e87..fb4f6f263 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -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." diff --git a/packages/core/src/plugin/skill/configure-hooks.md b/packages/core/src/plugin/skill/configure-hooks.md index b8a61c66a..ff3c59f01 100644 --- a/packages/core/src/plugin/skill/configure-hooks.md +++ b/packages/core/src/plugin/skill/configure-hooks.md @@ -1,229 +1,355 @@ # 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). +Hooks run a command, MCP tool, HTTP endpoint, prompt, or agent when an event +occurs. Configure them in `hooks.json`; `opencode.json` and Claude's settings +files are not hook sources. `/import-claude-hooks` migrates Claude settings. -## Where files live +## Files, merge, reload, and trust -| Scope | Path | Hot-reloaded? | -| -------- | --------------------------------- | --------------------------------- | -| Global | `~/.config/opencode/hooks.json` | Yes — polled every ~2s | -| Project | `.opencode/hooks.json` | Yes — polled every ~2s | -| Worktree | `/.opencode/hooks.json` | Yes (when worktree ≠ project dir) | +| Scope | File | Behavior | +| -------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| Global | `${Global.Path.config}/hooks.json` | Shared across projects; follows the XDG config directory (commonly `$XDG_CONFIG_HOME/opencode/hooks.json`, usually `~/.config/opencode/hooks.json`) | +| Project | `.opencode/hooks.json` | Project configuration | +| Worktree | `/.opencode/hooks.json` | Loaded when the worktree directory differs from the project directory | -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. +Layers concatenate in global, project, then worktree order; entries are not +overrides. Entries inside each event retain file order. All three files are +watched and reload after a change (about 2 seconds polling plus debounce); a +restart is normally unnecessary. Invalid entries are logged and skipped while +valid siblings remain active. `settings.json` and `.claude/settings*.json` +hook fields are ignored. -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. +Optional top-level trust controls: -## File format +- `requireTrust: true` enables the hook trust gate for the merged chain (also + enabled by `OPENCODE_HOOKS_REQUIRE_TRUST=1`). Hooks are skipped for an + untrusted working directory. +- `allowUntrusted: true` opts out of that gate only when declared in the global + hooks file. Project/worktree values are ignored; a repository cannot grant + itself trust. With neither setting, there is no trust gate unless + `OPENCODE_HOOKS_REQUIRE_TRUST=1` enables it. -Top-level keys are event names; each maps to a list of matcher blocks: +Example: ```json { "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [{ "type": "command", "command": "./scripts/check.sh", "timeout": 10 }] - } + { "matcher": "Bash", "hooks": [{ "type": "command", "command": "./scripts/check.sh", "timeout": 10 }] } ], - "SessionStart": [ - { - "matcher": "*", - "hooks": [{ "type": "command", "command": "./scripts/welcome.sh" }] - } - ] + "SessionStart": [{ "matcher": "startup", "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 -- names using letters, digits or underscores — exact match (case-insensitive), optionally pipe-separated -- other strings — treated as a regex tested against the target - -## Events (26 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` -MCP elicitation: `Elicitation`, `ElicitationResult` -Other: `Notification`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, -`InstructionsLoaded`, `CwdChanged`, `FileChanged` - -Removed event: `TeammateIdle` — no teammate concept exists in opencode, so it -could never fire. Entries naming it in hooks.json are silently skipped; delete -them. - -`Elicitation` / `ElicitationResult` fire when an MCP server issues -`elicitation/create`; the ask is mapped onto the Question UI, validated against -the requested schema, and answered accept/decline/cancel. `Notification` fires -for "agent needs attention" moments (permission asks, MCP elicitation asks) -through the internal Notification emitter; hook commands are the only external -effect today (OS/desktop delivery is a future change). A blocking `Elicitation` -hook deny short-circuits to `decline` without surfacing; unanswered elicitations -decline after 5 minutes (headless compositions decline immediately). - -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____`. | -| `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; stdout control fields are ignored -- `${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_` env vars in the subprocess - -### `inputFormat` — Claude Code envelope naming (command hooks only) - -Command hooks accept an optional `inputFormat` field: - -- `"opencode"` (default): the stdin envelope carries native OpenCode naming — - lowercase tool ids (`bash`, `read`, `grep`, …) and native input keys - (`filePath`, `oldString`, `include`, …). -- `"claude-code"`: the envelope is translated toward Claude Code naming so an - unmodified CC hook script keeps working: - - `tool_name` is canonicalized case-insensitively for known builtins only: - `bash`→`Bash`, `read`→`Read`, `write`→`Write`, `edit`→`Edit`, - `grep`→`Grep`, `glob`→`Glob`. Names outside this six-name set, including - MCP and custom names, pass through unchanged. A custom tool shadowing one - of these names receives the same naming translation. - - `tool_input` keys are mapped: `filePath`→`file_path` (read/write/edit); - `oldString`/`newString`/`replaceAll`→`old_string`/`new_string`/`replace_all` - (edit); `include`→`glob` (grep). Original keys and unrelated fields are - preserved; where a Claude key is already present with a non-null value, - it wins and the native key is not written over it. - - On `PreToolUse`, for the same six known names, a - `hookSpecificOutput.updatedInput` returned by the hook is translated back - to native keys before the tool runs; explicit Claude keys win over - translated aliases. The rewrite direction also applies only to known - names: for tools outside the set, a returned `updatedInput` is passed - through unchanged, so such a hook must emit native keys (`filePath`, - `oldString`, `include`, …) to rewrite a call. - -This is naming compatibility only, not full tool semantic equivalence: hook -output handling and tool responses are unchanged, and behavioral differences -between Claude Code and OpenCode tools still surface. Verify a ported hook by -actually running it and checking what it receives on stdin, what it returns, -and any context it injects into the session — the hook listing in the Active -Hooks block, or exiting 0, does not prove its augmentation logic fired. - -Example — reusing a Claude Code edit-guard hook unchanged: +## Events (26 total), input, and matcher targets + +Every handler receives a JSON envelope with `hook_event_name`, `session_id`, +`transcript_path`, and `cwd`; `transcript_path` is currently an empty string. +When available it also has `permission_mode` (`plan` or `default`), +`agent_id`, and `agent_type`. Event-specific fields below use the actual +snake_case names in command stdin and HTTP request bodies. Optional values are +omitted unless noted. `inputFormat: "claude-code"` changes only the documented +tool names/argument keys for command hooks; see below. + +| Event | When it fires; event-specific fields | Matcher target | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | +| `PreToolUse` | Before a dispatched tool call; `tool_use_id?`, `tool_name`, `tool_input` | `tool_name` | +| `PostToolUse` | After a successful tool call; `tool_use_id?`, `tool_name`, `tool_input`, `tool_response` | `tool_name` | +| `PostToolUseFailure` | After a non-interruption tool failure (aborts do not emit this event); `tool_use_id?`, `tool_name`, `tool_input`, `error`, `is_interrupt?` (accepted, not currently populated) | `tool_name` | +| `PermissionRequest` | When a tool needs permission; `tool_use_id?`, `tool_name`, `tool_input`, `permission_suggestions?` | `tool_name` | +| `PermissionDenied` | When a tool call is denied; `tool_use_id?`, `tool_name`, `tool_input`, `reason` | `tool_name` | +| `Setup` | Once per instance bootstrap; `trigger` (`startup`), with empty `session_id` | `trigger` | +| `SessionStart` | For a new top-level session only; currently `source` is always `startup` (other values in the accepted type are not emitted), with `session_id` set | `source` | +| `SessionEnd` | On session deletion only; currently `reason` is always `delete` (clear/logout/CLI exit do not emit it) | `reason` | +| `Stop` | When the assistant finishes a turn cleanly; `stop_hook_active`, `last_assistant_message?` | none | +| `StopFailure` | When a turn ends with an error; `stop_hook_active`, `error`, `last_assistant_message?` | none | +| `SubagentStart` | When the task tool starts a subagent; `agent_id` (child ID), `agent_type`; `session_id` is the parent | `agent_type` | +| `SubagentStop` | After a foreground task-tool child finishes cleanly; `stop_hook_active`, `agent_id`, `agent_type`. `session_id` is the parent; `agent_id` identifies the child. `agent_transcript_path` is accepted but not populated. | `agent_type` | +| `UserPromptSubmit` | On prompt admission (async-hook reminders skip this event to prevent recursion); `prompt` | none | +| `PreCompact` | Before context compaction; `trigger` (`manual`/`auto`), `custom_instructions` (empty when absent) | `trigger` | +| `PostCompact` | After compaction; `trigger?`, `compact_summary?`, `custom_instructions?` | `trigger` | +| `TaskCreated` | When the task tool creates a task; `task_id`, `task_title`. `task_description` is accepted but not populated. | none | +| `TaskCompleted` | After the task tool child prompt succeeds; `task_id`, `task_title`, `result` | none | +| `Elicitation` | For supported MCP `elicitation/create` form requests with a valid form schema, session, and Question UI; `prompt`, `schema`. Unsupported modes/schemas or missing interaction/session context decline before this hook. | none | +| `ElicitationResult` | After that form flow resolves; `result` on accept or `cancelled` on decline/dismiss/timeout | none | +| `Notification` | For current internal permission-ask and MCP-elicitation attention notifications; `message`, `title?`, `notification_type?` | `notification_type` | +| `ConfigChange` | When a watched `hooks.json` layer reloads (not arbitrary configuration changes); `config_path`, empty `session_id` | none | +| `WorktreeCreate` | After `createFromInfo` returns; bootstrap is asynchronous and may still be running. A failed worktree reset can also return and fire this event; it does not certify readiness or success; `path?`, `branch?` | none | +| `WorktreeRemove` | Before registered-worktree cleanup, including a stale or missing path; it does not certify successful removal; `path?`, `branch?` | none | +| `InstructionsLoaded` | When instruction content is loaded; `path?`, `content?` | none | +| `CwdChanged` | When OpenCode switches into a newly booted worktree, not an arbitrary shell directory change; `old_cwd`, `new_cwd`, empty `session_id` | none | +| `FileChanged` | After successful internal `write`, `edit`, `apply_patch`, `multiedit`, or `patch` tool operations; `path` (absolute), `change_type` (`add`, `change`, `delete`). It is not an external filesystem watcher. | basename of `path` | + +`matcher` supports `*`, case-insensitive exact names or pipe-separated lists +containing only letters, digits, underscores, and `|`. Other strings are +case-insensitive regexes; anchor them for an exact match (for example +`^(foo-bar|baz)$`). Its target is event-specific: the five tool/permission events use the +tool name; `SessionStart`, `SessionEnd`, `Setup`, `PreCompact`/`PostCompact`, +`Notification`, `SubagentStart`/`SubagentStop`, and `FileChanged` use the +target shown above. For `FileChanged`, match a basename such as `package.json`, +not the full path; its envelope `path` is absolute, and a rename can report the +removed path and added destination separately. When the target is `none`, a +configured matcher is ignored and every group runs (including `Stop`); omit it +for clarity. An omitted +matcher or `"*"` matches all values of a supported target. + +`TeammateIdle` is unsupported and silently skipped. The event type schema +accepts some fields that current producers do not populate; those are marked +above so a hook should not wait for them. `InstructionsLoaded` fires only for +local instruction files, once per path per instance, with content truncated +to the first 32,768 JavaScript string code units; remote instruction URLs do +not fire it. Task and subagent events come from the task-tool workflow; background promotion does not mean the child +has stopped. `Notification` is currently emitted for permission asks and +supported MCP elicitation attention; it does not itself send an OS notification. + +## Hook types and configuration fields + +All five types are implemented. Each handler has a positive finite `timeout` +in seconds (default 60); timeout cancels the running process/request. `type` +is required. Provide the matching action: `command` for command and MCP, +`url` for HTTP, `prompt` for prompt and agent. For compatibility, HTTP also +accepts `command` as its URL and prompt/agent accept `command` as their text. + +| Field | Applies to | Meaning and limits | +| ---------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `command` | command, MCP; legacy action alias for HTTP/prompt/agent | Shell command, `mcp____` name, or legacy URL/prompt text. | +| `url` | HTTP | POST target. | +| `prompt` | prompt, agent | Prompt text or agent goal. | +| `headers` | HTTP | Request headers. | +| `allowedEnvVars` | HTTP | Expand `$NAME` / `${NAME}` in headers only for listed, set names. Unlisted or unset names become empty; when omitted, header values remain literal. | +| `shell` | command | `bash` or `powershell`; must be installed. Omitted uses `/bin/sh` on POSIX or `cmd.exe` on Windows. | +| `inputFormat` | command | `opencode` (default) or `claude-code`; naming adapter described below. | +| `statusMessage` | all | Logged before dispatch; does not show UI progress. | +| `once` | all | Run this loaded entry once per session, including async/concurrent triggers. Reload creates fresh entries. Dynamic session registrations also support group-level `once`, claimed only after conditions match. | +| `if` | all; effective only for tool/permission events | Condition such as `Bash(npm install *)` or `Edit(*.ts)`. Empty, `*`, and malformed conditions fail open. On non-tool events it is ignored. | +| `async` | all | Run in a background fiber. Its result cannot affect the current trigger's permission, block, or input decision. Work is process-local and is not persisted across restart. | +| `asyncRewake` | paired with `async` | Admit useful completion feedback into the originating session. It is inert without `async`; `SessionEnd` and missing session IDs cannot rewake. Details below. | +| `options` | command | Export each value as `CLAUDE_PLUGIN_OPTION_` containing its JSON serialization. | +| `__sourceDir` | runtime metadata | Injected by OpenCode; do not configure it. It identifies the declaring directory for root and data-path resolution. | + +Agent hooks have `read_file`, `list_dir`, `grep`, and restricted `bash` tools. +That `bash` executes validated installed utilities directly, without a shell; +interpreters, shell composition, and project-controlled PATH commands are +unavailable. On Windows use the file tools. + +| Type | Input / result protocol | Failure behavior | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| `command` | JSON envelope on stdin; exit 0 parses JSON stdout; exit 2 blocks with stderr; plain-text exit-0 stdout is model context only for `UserPromptSubmit` / `SessionStart`. | Other nonzero exit, spawn failure, or timeout logs and allows the flow to continue. | +| `mcp` | Calls the named `mcp____` with the full envelope as tool arguments; parses the first text content item as JSON output. | Missing tool, no text/invalid JSON, MCP error, or timeout logs and allows the flow to continue. | +| `http` | POSTs the full envelope as JSON; parses a 2xx response body as JSON output. | Non-2xx blocks. Network failure, missing HTTP service, or timeout logs and allows the flow to continue. | +| `prompt` | Sends hook prompt as system message and envelope JSON as user input; requests structured hook output using OpenCode's configured default model, not the session model. | Missing provider/auth, unsupported OpenAI OAuth, generation/schema failure, or timeout logs and allows the flow to continue. | +| `agent` | Sends prompt and envelope to OpenCode's configured default model with read-only inspection tools and `synthetic_output` for the final hook result. Up to 200 model turns, but default timeout is 60 seconds. | Missing final output, missing services, OAuth, model failure, or timeout logs and allows the flow to continue. | + +### Command stdin and Claude Code naming + +Command stdin is the event envelope above. Exit code 0 is success; JSON stdout +is parsed as hook output. Exit code 2 blocks and stderr supplies the reason. +Other exit codes and timeouts are warnings; their stdout control fields are +ignored. Plain-text exit-0 stdout is injected as context only for +`UserPromptSubmit` and `SessionStart`. + +`inputFormat: "claude-code"` adapts command stdin and PreToolUse `updatedInput` +by tool ID: `bash`/`read`/`write`/`edit`/`grep`/`glob` become +`Bash`/`Read`/`Write`/`Edit`/`Grep`/`Glob`; known argument keys such as +`filePath`→`file_path`, edit's `oldString`→`old_string`, and grep's +`include`→`glob` are translated. Custom tools shadowing these IDs receive the +same translation regardless of their origin. Tools outside this set, including +MCP/custom tools with other IDs, pass through unchanged; emit native keys when +rewriting those tools. This is naming +compatibility, not full Claude Code behavior or output compatibility. + +`${CLAUDE_PLUGIN_ROOT}` expands to the directory containing the declaring +hooks file. `${CLAUDE_PLUGIN_DATA}` expands to a path under +`${Global.Path.data}/hooks/` with an identifier derived from that directory; +the script may need to create it. Both values are also exported as environment +variables; `CLAUDE_PROJECT_DIR` contains the hook execution directory (`cwd`). +Command hooks inherit the host process environment and run with its OS +permissions. `options` values are serialized into `CLAUDE_PLUGIN_OPTION_` +variables (keys uppercased, non-alphanumeric characters replaced with `_`). +`${user_config.}` interpolates an option directly into the command (strings unchanged, other values serialized as JSON). Unknown or +unresolved templates remain unchanged for the shell to handle. Quote paths +and option values appropriately for the chosen shell. + +## Hook output: accepted fields and actual effects + +Handlers may return a JSON object with these top-level fields: + +| Field | Runtime effect / limit | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `continue: false` | Stops prompt admission/continuation where the event consumer honors it; it takes precedence over a Stop hook's request to continue. | +| `stopReason` | Used as the reason when continuation is stopped. | +| `decision: "block"` | Requests a block with `reason` (or a generic reason), where the event caller honors it. `"approve"` does not grant tool permission; use `hookSpecificOutput.permissionDecision` on permission events. | +| `reason` | Block explanation for `decision: "block"`. | +| `systemMessage` | Logged, and delivered as session feedback by consumers that land hook system messages. | +| `suppressOutput` | Accepted for compatibility; no-op. Hook stdout is not shown directly in the UI. | +| `hookSpecificOutput` | Event-tagged object below. The runtime aggregates supported fields by property; the tag does not make an unsupported field effective. | + +Supported `hookSpecificOutput` fields: + +- `hookEventName`: conventional tag (for example `PreToolUse`); it does not + override the actual triggering event. +- `permissionDecision`: `allow`, `deny`, or `ask`; consumed by + `PreToolUse`/`PermissionRequest`. Most restrictive wins (`deny` > `ask` > + `allow`). `ask` forces a user dialog where available; the task-tool path + degrades it to deny. Headless `opencode run` automatically rejects requests, + or approves them with `--dangerously-skip-permissions`; neither obtains human + confirmation. `permissionDecisionReason` supplies its explanation. + On interactive `PreToolUse`, `ask` shows the proposed input, including + any `updatedInput`. On a forced ask handled by `PermissionRequest`, `deny` + may reject the request; `allow` is ignored and logged. +- `updatedInput`: changes arguments before a `PreToolUse` tool runs. It cannot + change a tool after execution or change non-tool events. +- `additionalContext`: model-facing context where the triggering event's + consumer supports it. For post-tool events it is feedback after the action + and cannot undo that action; repeated context may be deduplicated. +- `permissionDecisionReason`: reason shown with a permission decision. + +The synchronous caller determines which aggregate results change control flow; +the same schema does not mean every event consumes every field: + +| Event(s) | What the caller actually consumes | +| -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PreToolUse` | `blocked` or `permissionDecision: deny` denies; `ask` forces a user dialog where available (the task-tool path degrades it to deny); `allow` does not bypass the tool's normal permission checks. `updatedInput` shallow-merges into tool args before execution; `continue: false` stops the call/turn. `additionalContext` is added to tool feedback if execution continues. `systemMessage` is logged. | +| `PermissionRequest` | `blocked`, `continue: false`, or `permissionDecision: deny` rejects the ask; `allow` approves only a normal ask, not a hook-forced ask. `ask` has no extra effect because the user is already being asked. `systemMessage` is logged; `additionalContext` is not injected. | +| `PermissionDenied` | Fired after a permission-rule denial; only `systemMessage` is logged. Other output cannot reverse the denial or add model feedback. | +| `UserPromptSubmit` | `blocked` or `continue: false` stops the prompt before model admission. If admitted, `additionalContext` and `systemMessage` are added as synthetic model-visible parts; on a blocked prompt, `systemMessage` is logged only. | +| `SessionStart` | `additionalContext` is queued for the session's next prompt; `systemMessage` is logged. Current producer fires only for top-level startup, with empty transcript path. | +| `PostToolUse`, `PostToolUseFailure`, `FileChanged` | Supported context, system message, block reason, and stop reason are appended to the completed tool result/error as model feedback. They cannot undo a successful side effect. `PostToolUse` `continue: false` ends the turn after recording its result. | +| `PreCompact` | `blocked` cancels compaction. The caller records `systemMessage`; it ignores `additionalContext`. `continue: false` is not a compaction control. | +| `PostCompact` | `systemMessage` is logged. `additionalContext` and display/summary fields are not injected by this caller. | +| `Stop` | A clean-turn `blocked` result requests another model turn; `continue: false` prevents that continuation. Context and system messages are included in turn feedback. Continuation is capped at five. | +| `StopFailure` | Error-turn feedback records `additionalContext` and `systemMessage`; the synchronous block reason is not separately injected because an errored turn cannot continue. A completion callback may rewake the originating session. | +| `SubagentStop` | A foreground task-tool child is re-prompted when blocked, unless `continue: false`; at most five continuations. `session_id` is the parent session and `agent_id` is the child. `systemMessage` is logged; `additionalContext` is not separately injected. | +| `TaskCreated`, `TaskCompleted`, `SubagentStart` | The task-tool caller logs `systemMessage`; it ignores block, continuation, and context output. | +| `Elicitation` | A blocking result declines the supported form request before showing the Question UI. `systemMessage` is logged. | +| `ElicitationResult`, `Notification`, `Setup`, `SessionEnd`, `ConfigChange`, `CwdChanged`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded` | These callers do not use hook output to alter the surrounding operation. Whether `systemMessage` or context is landed varies by caller and is not guaranteed model feedback. | + +The schema also accepts `initialUserMessage`, `watchPaths`, +`updatedMCPToolOutput`, `displayMessage`, `compactSummary`, and `customSummary` +for compatibility, but OpenCode warns that they are unsupported and ignores +them. Use `additionalContext` for supported model feedback. Invalid JSON or +output shapes are logged and ignored. Prompt/agent hooks using OpenAI OAuth are +skipped with a warning; use a supported API-key provider. + +## Transient session hooks + +An HTTP client can register a hook for one existing session with `POST +/session/{sessionID}/hook`, list that session's registrations with `GET` on +the same path, and remove one by ID with `DELETE +/session/{sessionID}/hook/{hookID}`. A registration body has required `event` +and non-empty `hooks`, plus optional top-level `matcher` and `once`. Each hook +entry supports the normal handler fields, including optional `if` and its own +`once`; entry-level `once` removes the whole registration before its first +matching execution attempt, while hook-level `once` suppresses that handler +after its first execution attempt for the session. `if` is only evaluated on +tool/permission events. +These entries join the regular matcher and result-aggregation pipeline but are +held in process memory, scoped to the session and its OpenCode instance. They +are cleared after `SessionEnd` (currently deletion only), when the owning +instance directory is disposed, and on process restart. Keep the same host and +instance alive while relying on them. The **Active Hooks** file summary lists +the on-disk chain, not transient session registrations; use the session hook +list endpoint to inspect those. + +## Create and verify + +For a hook requested in ordinary conversation, load this skill, read the +relevant global/project/worktree files, and append to the existing event list +without overwriting sibling hooks. Choose the narrowest event and matcher; +use `if` only for a tool/permission event. Keep command stdout clean JSON when +returning hook controls (send diagnostics to stderr), and set a bounded timeout +with margin for expected completion. + +After the final write, wait at least the 2-second polling interval plus the +500-millisecond debounce before ending the first Stop turn or treating the +change as reloaded. The **Active Hooks** block confirms loaded file +configuration only. Verify execution with a controlled trigger and an +independent, safe signal such as a temporary marker or test endpoint response; +do not infer execution from the listing alone. + +### Async completion and `asyncRewake` + +Use `async: true` when a handler may take time and should not hold up the +current hook trigger. OpenCode requires both flags (`async: true` and +`asyncRewake: true`) for a completion callback; `asyncRewake` by itself is +inert. Claude Code also has an `asyncRewake` field, but its behavior differs: it +implicitly runs it asynchronously and wakes on exit code 2, while OpenCode +requires both flags and can wake on exit-0 JSON `systemMessage` or other output +listed below. `asyncRewake: true` then delivers useful output only +after that handler has completely exited/settled; it does not poll or wake the +model while the handler is still running. A simple bounded poll can run inside +the command, for example up to the default 60-second timeout, then exit with a +result. The callback is in memory: it is lost if OpenCode exits, and it is +discarded if a newer non-hook prompt or session end makes a pending Stop +callback stale. For task-tool `SubagentStop`, `session_id` is the parent and +`agent_id` is the stopped child; an async rewake targets the parent session and +does not resume that child. + +Completion feedback that can trigger a rewake is: an `exitBlock` (command exit +code 2 stderr or its fallback reason, or an HTTP non-2xx status failure), +`systemMessage`, a `decision: "block"` with a `reason`, +`hookSpecificOutput.additionalContext`, or non-JSON plain text on exit 0 for +`UserPromptSubmit` / `SessionStart`. Permission decisions, `updatedInput`, +`stopReason`, and other fields do not by themselves trigger a rewake. The +completed output is wrapped in a synthetic system reminder and admitted into +the same session. In particular, valid JSON stdout with `systemMessage` on +exit 0 is completion feedback and rewakes. `SessionEnd` never rewakes, even if +output is present. ```json { - "PreToolUse": [ + "Stop": [ { - "matcher": "Edit", - "hooks": [{ "type": "command", "command": "./scripts/guard.py", "inputFormat": "claude-code" }] + "hooks": [ + { + "type": "command", + "command": "./scripts/poll-and-report.sh", + "timeout": 60, + "async": true, + "asyncRewake": true + } + ] } ] } ``` -`guard.py` sees `tool_name: "Edit"` and `tool_input.file_path` / -`old_string` / `new_string`; its permission-decision output is handled exactly -as in Claude Code. - -### Handler options and agent tools - -- `timeout`: positive seconds; cancellation reaches the running process, model request or MCP tool. -- `shell`: command hooks can explicitly select `bash` or `powershell`; that interpreter must be installed. Omit it for `/bin/sh` on POSIX or `cmd.exe` on Windows. -- `inputFormat`: command hooks only — `"opencode"` (default) or `"claude-code"` for Claude Code envelope naming; contract in the `inputFormat` section above. -- `allowedEnvVars`: when supplied, HTTP hooks expand `$NAME` and `${NAME}` in headers only for names in this list. Unlisted or unset variables expand to an empty string. When omitted, headers remain literal. -- `statusMessage`: recorded in the hook execution log before dispatch; it does not create a UI progress indicator. -- Command-level `once`: runs once per session for the current loaded configuration entry, including concurrent/async triggers. Reloading the file creates fresh entries. Session registration-level `once` atomically claims the whole matching group; unmatched conditions leave it available. -- Dynamic session registration supports the same command fields, including `options`. - -Agent hooks provide `read_file`, `list_dir`, `grep`, and a restricted `bash` tool. -The latter directly executes installed POSIX system utilities with validated arguments; -it does not invoke a shell. It supports common read-only options for file inspection, -`find` predicates and `git status/log/diff/show`. `sed` is limited to -`-n '[,]p' `. Interpreters such as `awk`, shell composition, -output-file options, external Git diff drivers and commands found through a -project-controlled PATH are unavailable. Use the three file tools on Windows. - -### 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" - } -} -``` - -`continue: false` stops prompt admission and wins over a Stop hook's request to -continue. Permission hooks can reject using `permissionDecision: "deny"`, a block -decision, or exit code 2. - -A PreToolUse `permissionDecision: "ask"` is a forced ask: the user always -confirms it, even after an earlier "always" approval, and the dialog shows the -input the tool will run with, including any `updatedInput`, plus the hook's -`permissionDecisionReason`. A PermissionRequest hook can deny a forced ask, but -its `allow` is ignored (logged). Once confirmed, the tool's own ask-level -permission checks for that call do not prompt again; deny rules still apply. -`opencode run --dangerously-skip-permissions` is an automatic mode and approves -forced asks once; without it, `run` rejects them. - -Post-tool block reasons and contexts are appended to -model-facing tool feedback after execution; they cannot undo a completed write. -`FileChanged` carries an absolute path and `add`, `change` or `delete`; patch -renames report both the removed path and the added destination. - -Some fields are retained only for schema compatibility. `initialUserMessage`, -`watchPaths`, `updatedMCPToolOutput`, `displayMessage`, `compactSummary` and -`customSummary` emit unsupported-output warnings and do not alter runtime state. -Use `additionalContext` for model feedback. `suppressOutput` remains a no-op: -hook stdout is not displayed directly in the UI. Prompt/agent hooks using OpenAI -OAuth are currently skipped with a warning; use a supported API-key provider. -Invalid output shapes are logged and ignored before aggregation. - -## Applying changes - -Global, project and worktree `hooks.json` files are polled every ~2 seconds, -with a 500 ms debounce. Changes take effect within a few seconds. Invalid -matcher/command entries are logged and skipped while valid siblings remain active. - -## 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. +The script may poll for readiness within its timeout. On completion, emit a +useful JSON result such as `{"systemMessage":"The background check is ready."}` +to stdout and exit 0. The model receives that message only after the script +exits; do not use `asyncRewake` as a progress signal. + +`Stop` is the clean end of an assistant turn; a blocking synchronous Stop hook +can request another turn. `StopFailure` is selected when the turn ended with +an error and does not drive that synchronous clean-turn continuation; an async +completion can still deliver useful feedback. Async Stop completions share a +maximum of five continuations in the hook chain. After a +rewake, subsequent Stop envelopes carry `stop_hook_active: true`; check this +flag and make the script idempotent or it can repeatedly block and consume +the full cap. `stop_hook_active` does not mean the session ended. `SessionEnd` +currently fires on session deletion only, clears session hook state, and +cannot deliver an async rewake. + +## Migration + +`/import-claude-hooks` reads Claude settings and offers each candidate for +review. Claude hook settings are not loaded directly. OpenCode's deprecated +`settings.json` hook fields are ignored and produce a warning. Preserve Claude +files and scripts unless the user explicitly asks to remove confirmed-unused +items. + +Imported command hooks default to `inputFormat: "claude-code"`; other hook +types receive no envelope translation, so their behavior needs manual review. +Claude Code also supports an `asyncRewake` field, but its semantics differ: +that field implies background execution and wakes on exit code 2. OpenCode +requires both `async: true` and `asyncRewake: true`, and its callback can wake +for additional result types. Inspect each source hook's intended behavior and +explicitly adapt it; do not blindly copy the field or assume its implicit +async behavior. Preserve ordinary source `async` only after checking that its +OpenCode handler type and timeout behavior are appropriate. Claude's +`TeammateIdle` has no OpenCode equivalent and is skipped. diff --git a/packages/core/src/plugin/skill/customize-opencode.md b/packages/core/src/plugin/skill/customize-opencode.md index 74eeeb857..cffeb57d8 100644 --- a/packages/core/src/plugin/skill/customize-opencode.md +++ b/packages/core/src/plugin/skill/customize-opencode.md @@ -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/.md` or `.opencode/agents/.md` | | Global agents | `~/.config/opencode/agent(s)/.md` | | Project commands | `.opencode/command/.md` or `.opencode/commands/.md` | @@ -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: { ... }`. diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index bc109df45..1b89031aa 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -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, diff --git a/packages/opencode/src/command/template/create-hook.txt b/packages/opencode/src/command/template/create-hook.txt index 064534c7d..3816cb7fd 100644 --- a/packages/opencode/src/command/template/create-hook.txt +++ b/packages/opencode/src/command/template/create-hook.txt @@ -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: @@ -44,7 +47,11 @@ 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 @@ -52,18 +59,31 @@ Ask the user for each field, one at a time: - 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` → `/.opencode/hooks.json` when the worktree is separate ### 2. Build the JSON entry @@ -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 { @@ -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. @@ -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. ``` diff --git a/packages/opencode/src/command/template/import-claude-hooks.txt b/packages/opencode/src/command/template/import-claude-hooks.txt index 0a60521a7..d1c907345 100644 --- a/packages/opencode/src/command/template/import-claude-hooks.txt +++ b/packages/opencode/src/command/template/import-claude-hooks.txt @@ -6,6 +6,10 @@ description: Import hooks from Claude Code config to OpenCode hooks.json Migrate hooks from Claude Code's `.claude/settings.json` files to OpenCode's `hooks.json` format. Hooks are not loaded directly from `.claude/settings*.json`; use this command to migrate them. OpenCode can still load `.claude/skills` by default unless that compatibility loading is disabled. +Load `configure-hooks` first for the current event, handler, input/output, and +compatibility contracts. Infer approval already given by the user; present +unapproved candidates for review without re-asking about approved ones. + ## Process ### 1. Scan for Claude hooks @@ -49,11 +53,19 @@ Ask the user for each hook: ### 3. Handle path migration -When importing, fix any hardcoded `.claude/` paths: +Resolve each script and its dependencies before rewriting paths. Writing a +hook definition does not copy a Claude plugin or make an OpenCode destination +exist. Keep a working source path, or copy the required assets into the chosen +OpenCode location under the user's migration authorization and verify them. -- Replace `${CLAUDE_PLUGIN_ROOT}` references that assume `.claude/` with `.opencode/` -- Replace absolute paths like `~/.claude/plugins/...` with `~/.config/opencode/plugins/...` -- Update `__sourceDir` stamps from `.claude/` to `.opencode/` +`${CLAUDE_PLUGIN_ROOT}` is the directory containing the declaring hooks file. +`${CLAUDE_PLUGIN_DATA}` is a data path under `${Global.Path.data}/hooks/`, with +an identifier derived from that directory; it is not inside the source folder. +The script may need to create its data directory. Preserve the placeholder +syntax; verify script paths and dependency assets before rewriting them. +Do not author `__sourceDir`: OpenCode stamps that metadata when loading the +file. Never blindly replace `.claude/` with `.opencode/` and assume the script +has moved. Example migration: ```json @@ -79,7 +91,7 @@ Example migration: ] } ``` -`${CLAUDE_PLUGIN_ROOT}` is a runtime placeholder. OpenCode expands it from the hook's location. After migration to `.opencode/`, it points there. +`${CLAUDE_PLUGIN_ROOT}` is a runtime placeholder. OpenCode expands it from the hook's location. After migration to `.opencode/`, it points there; this example therefore requires `validate.sh` and its dependencies to exist there. #### Command-envelope compatibility (`inputFormat`) @@ -95,7 +107,7 @@ Other hook types receive no translation. For each imported `mcp` / `http` / `pro Approved hooks go to: -- **Global scope** (from `~/.claude/settings.json`): `~/.config/opencode/hooks.json` +- **Global scope** (from `~/.claude/settings.json`): `${Global.Path.config}/hooks.json` (the XDG config directory, commonly `$XDG_CONFIG_HOME/opencode`, usually `~/.config/opencode`) - **Project scope** (from `.claude/settings.json` or `.claude/settings.local.json`): `.opencode/hooks.json` Use top-level event keys. Do not wrap them in `{"hooks": {...}}`. Each event maps to matcher blocks with an optional `matcher` and a `hooks` array: @@ -126,14 +138,14 @@ If the target file already exists, merge (append) the imported hooks rather than After migration, report: -- Number of hooks imported (by scope: global/project) +- Number of hooks imported (by destination scope: global/project) - Number of hooks skipped - Any hooks that were edited during migration - Number of command hooks tagged `inputFormat: "claude-code"` - For every imported non-command hook: a warning that stdin-envelope compatibility was not verified and needs manual evaluation - Deprecation warnings (if hooks were found in .opencode/settings.json) -- Keep `.claude/settings*.json`, scripts, and skills in place during migration. After a successful migration and validation, you may remove specific confirmed-unused Claude hook configuration if you no longer need it. Do not recommend deleting the entire `.claude/` directory. -- Imported hooks appear in the system prompt's **Active Hooks** block. It renders from live `hooks.json` state each turn, so no `AGENTS.md` update is needed. Visibility does not prove behavior. Trigger each hook and confirm its stdin and output before calling migration done. +- Keep `.claude/settings*.json`, scripts, and skills in place during migration. Remove specific confirmed-unused Claude hook configuration only when the user explicitly requests removal. Do not recommend deleting the entire `.claude/` directory. +- After the final write, wait at least the 2-second poll interval plus the 500-millisecond debounce before checking reload. The system prompt's **Active Hooks** block confirms loaded configuration only; it does not prove behavior. Trigger each hook and inspect its input/output before calling migration done when behavior validation is requested. ## Hooks.json Format Reference @@ -154,7 +166,7 @@ Note: Claude Code's `TeammateIdle` event is not supported (no teammate concept i Each event maps to an array of **matcher blocks**. Each matcher block has: -- `matcher`: optional — `"*"` (default, all tools), exact name (`"Bash"`), pipe list (`"Bash|Edit"`), or regex +- `matcher`: optional — `"*"`, exact name, pipe list, or regex. It matches `tool_name` on tool/permission events and supported lifecycle targets (`SessionStart.source`, `SessionEnd.reason`, `Setup`/compaction `trigger`, `Notification.notification_type`, subagent `agent_type`, or `FileChanged` path basename). On events with no target, including `Stop`, a matcher is ignored and the group runs regardless; omit it. - `hooks`: array of hook entries Each hook entry has: @@ -163,6 +175,14 @@ Each hook entry has: - `command` / `url` / `prompt`: the hook action (field depends on type — `command` for command/mcp, `url` for http, `prompt` for prompt/agent) - `timeout`: optional seconds (default 60) - `inputFormat`: optional, command hooks only — `"opencode"` (default) or `"claude-code"`. The latter maps envelope `tool_name`/`tool_input` toward Claude Code naming; see `configure-hooks` for the contract. +- `async`: optional background execution flag. Claude Code has an analogous command-hook field; preserve it only after checking the source hook type and intended behavior against OpenCode's handler and timeout semantics. +- `asyncRewake`: Claude Code also has this field, but its semantics differ. In Claude Code it implies asynchronous execution and wakes on exit code 2. OpenCode requires both `async: true` and `asyncRewake: true`; its completion callback can wake for exit-0 JSON `systemMessage` and other documented results. Do not blindly copy the field or assume implicit async behavior. During migration, inspect and explicitly adapt each source hook. If adding it during an edit, explain OpenCode's same-session, Stop-loop, and timeout limits in `configure-hooks`. + +Other supported OpenCode fields (such as `shell`, `statusMessage`, `once`, +`if`, HTTP `headers` / `allowedEnvVars`, command `options`, and top-level +`requireTrust` / `allowUntrusted`) are not implied by migration. See +`configure-hooks` before adding them; respect their type, scope, and trust +limits. Merge semantics: concat-append (hooks accumulate across layers and within a file, not replace). @@ -172,3 +192,4 @@ Merge semantics: concat-append (hooks accumulate across layers and within a file - The `hooks` field in `.opencode/settings.json` is deprecated and ignored (deprecation warning logged). - All three `hooks.json` layers (global, project, worktree) are polled every ~2 seconds; edits take effect within a few seconds without a restart. - Hooks are merge-append, not override. Importing duplicate events will add to the list. +- For every field and event's actual stdin fields, matcher targets, output effects, and `asyncRewake` limits, use the self-contained `configure-hooks` skill. Do not assume that accepted schema fields have the same behavior in OpenCode as in Claude Code. diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 27836a21b..93f0268e1 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -172,6 +172,7 @@ export const layer = Layer.effect( const shown = hooks.slice(0, MAX) const lines = [ "## Active Hooks", + "For lifecycle-hook configuration, output protocols and async continuation, load the configure-hooks skill.", ...shown.map((h) => `- ${h.event} [${h.scope}/${h.type}] ${h.descriptor}`), ] if (hooks.length > MAX) lines.push(`… and ${hooks.length - MAX} more (see hooks.json)`) diff --git a/packages/opencode/test/hook/event-trigger-coverage.test.ts b/packages/opencode/test/hook/event-trigger-coverage.test.ts index c2e6160b2..cf2f6ea4a 100644 --- a/packages/opencode/test/hook/event-trigger-coverage.test.ts +++ b/packages/opencode/test/hook/event-trigger-coverage.test.ts @@ -38,7 +38,7 @@ describe("hook event trigger coverage", () => { expect(skill).toContain(`Events (${VALID_HOOK_EVENTS.size} total)`) expect(ConfigureHooksDescription).toContain(`the ${VALID_HOOK_EVENTS.size} supported events`) expect(create).toContain("Names using letters, digits or underscores — exact match (case-insensitive)") - expect(skill).toContain("names using letters, digits or underscores — exact match (case-insensitive)") + expect(skill).toContain("containing only letters, digits, underscores, and `|`") expect(migrate).toContain("Entries naming it are silently skipped") expect(skill).toContain("silently skipped") }) diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 66bf6f88f..b996e2b75 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -152,15 +152,14 @@ describe("session.system", () => { { event: "PreToolUse", scope: "project", type: "command", descriptor: "echo hi", matcher: "Bash" }, { event: "Stop", scope: "global", type: "http", descriptor: "https://example.com/stop" }, ] - const output = yield* prompt.hooks().pipe( - Effect.provide( - Layer.mock(SettingsHook.Service, { list: () => Effect.succeed(entries) }), - ), - ) + const output = yield* prompt + .hooks() + .pipe(Effect.provide(Layer.mock(SettingsHook.Service, { list: () => Effect.succeed(entries) }))) expect(output).toEqual([ [ "## Active Hooks", + "For lifecycle-hook configuration, output protocols and async continuation, load the configure-hooks skill.", "- PreToolUse [project/command] echo hi", "- Stop [global/http] https://example.com/stop", ].join("\n"), @@ -171,11 +170,9 @@ describe("session.system", () => { it.effect("hooks block is empty when SettingsHook list is empty", () => Effect.gen(function* () { const prompt = yield* SystemPrompt.Service - const output = yield* prompt.hooks().pipe( - Effect.provide( - Layer.mock(SettingsHook.Service, { list: () => Effect.succeed([]) }), - ), - ) + const output = yield* prompt + .hooks() + .pipe(Effect.provide(Layer.mock(SettingsHook.Service, { list: () => Effect.succeed([]) }))) expect(output).toEqual([]) }), @@ -200,11 +197,9 @@ describe("session.system", () => { type: "command" as const, descriptor: `cmd-${i}`, })) - const output = yield* prompt.hooks().pipe( - Effect.provide( - Layer.mock(SettingsHook.Service, { list: () => Effect.succeed(entries) }), - ), - ) + const output = yield* prompt + .hooks() + .pipe(Effect.provide(Layer.mock(SettingsHook.Service, { list: () => Effect.succeed(entries) }))) const block = output[0] expect(block).toContain("## Active Hooks")