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
8 changes: 0 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,11 +201,3 @@ Guiding invariants for adding services, HTTP API routes, or features. The build
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once.
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.

<!-- Hooks_START -->
## Active Hooks (auto-generated — do not edit between markers)

No hooks configured. Run `/import-claude-hooks` to migrate from Claude Code config, or manually create `hooks.json` files.

Full config: `~/.config/opencode/hooks.json` (global) · `.opencode/hooks.json` (project)
<!-- Hooks_END -->
12 changes: 8 additions & 4 deletions packages/core/src/plugin/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import configureHooksContent from "./skill/configure-hooks.md" with { type: "tex
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",
effect: Effect.fn(function* (ctx) {
Expand All @@ -21,8 +27,7 @@ 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,
}),
Expand All @@ -33,8 +38,7 @@ export const Plugin = define({
type: "embedded",
skill: SkillV2.Info.make({
name: "configure-hooks",
description:
"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.",
description: ConfigureHooksDescription,
location: AbsolutePath.make("/builtin/configure-hooks.md"),
content: ConfigureHooksContent,
}),
Expand Down
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
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
10 changes: 10 additions & 0 deletions packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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 @@ -49,6 +50,7 @@ export const Default = {
GOAL: "goal",
SUBGOAL: "subgoal",
IMPORT_HOOKS: "import-claude-hooks",
CREATE_HOOK: "create-hook",
} as const

export interface Interface {
Expand Down Expand Up @@ -111,6 +113,14 @@ export const layer = Layer.effect(
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
146 changes: 146 additions & 0 deletions packages/opencode/src/command/template/create-hook.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
---
description: Create a new hook interactively and write it to the correct hooks.json layer
---

# Create Hook

This command guides interactive authoring of a **single new hook entry** into the correct `hooks.json` file. It merge-appends (never overwrites) and validates the event name before writing.

## Prerequisites

Load the `configure-hooks` skill first — it documents the canonical 27-event list, the 5 hook types, and the `hooks.json` format. Reference it instead of guessing event names or field shapes.

## Process

### 1. Gather inputs interactively

Ask the user for each field, one at a time:

**Event** — one of the 27 valid events (see the `configure-hooks` skill for the full list). Tool lifecycle: `PreToolUse`, `PostToolUse`, `PostToolUseFailure`. Permission: `PermissionRequest`, `PermissionDenied`. Session: `Setup`, `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`. Subagents: `SubagentStart`, `SubagentStop`. Prompt/compaction: `UserPromptSubmit`, `PreCompact`, `PostCompact`. Tasks: `TaskCreated`, `TaskCompleted`, `TeammateIdle`. Other: `Notification`, `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged`.

**Reject invalid event names before doing anything else** — show the valid list and re-ask. Do NOT write the file for an unknown event.

**Hook type** — `command` | `mcp` | `http` | `prompt` | `agent`. Ask which action the hook should perform:

- `command` — run a shell command
- `mcp` — invoke an MCP tool (`mcp__<server>__<tool>`)
- `http` — POST the event envelope to a URL
- `prompt` — run an LLM call with structured output
- `agent` — run an autonomous sub-agent loop

**Action field** (depends on type):

- `command` → the shell command string
- `mcp` → the tool name in `mcp__<server>__<tool>` format
- `http` → the endpoint URL
- `prompt` / `agent` → the prompt / goal text

**Matcher** (only for tool-bound events: `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `PermissionDenied`):

- Empty / omitted / `*` — matches all tools
- `Bash` — exact tool name
- `Bash|Edit|Write` — pipe-separated list
- Any other string — treated as a regex

For non-tool events, omit the matcher entirely.

**Optional fields** — ask if the user wants to set any:

- `timeout` — seconds before the hook is killed (default 60)
- `statusMessage` — short label shown in the UI while the hook runs

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

- `project` → `.opencode/hooks.json` in the current project (hot-reloads in ~2s)
- `global` → `~/.config/opencode/hooks.json` (requires restart to take effect)

### 2. Build the JSON entry

Construct the hook entry matching the `hooks.json` format. The event key maps to an array of matcher blocks; each block has an optional `matcher` and a `hooks` array:

```json
{
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "echo 'hello from hook'", "timeout": 10 }
]
}
]
}
```

For non-tool events, omit the `matcher` field:

```json
{
"Stop": [
{
"hooks": [
{ "type": "command", "command": "echo 'session stopped'" }
]
}
]
}
```

### 3. Confirm with the user

Show the complete JSON entry (including the event name, matcher block, and hook object) and ask for confirmation before writing. If the user wants changes, edit and re-show.

### 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`

Then:

1. If the file does not exist, create it (create the `.opencode/` directory for project 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.
- Preserve all existing entries and any unknown fields (e.g. `$schema`, `version`) — only touch the event key you are adding to.
3. Write the merged JSON back with 2-space indentation.

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:

- If the parse succeeds, report success.
- If the parse fails, report the error and ask the user to check the file manually.

### 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**: "Global hooks load at startup. Restart opencode for this hook to take effect."

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.

## Example session

```
User: /create-hook
Agent: Which event should this hook fire on? (see the configure-hooks skill for all 27 events)
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.
User: Bash
Agent: Project or global scope? (project hot-reloads in ~2s; global requires restart)
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

✓ 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.
```
Loading
Loading