diff --git a/AGENTS.md b/AGENTS.md index 07c51dc457..909b34aa79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. - - -## 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) - diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index c54febe7ea..5a8bc85760 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -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) { @@ -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, }), @@ -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, }), diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 326b1bbddb..6605139903 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -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) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 3b568cb954..266f899868 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -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" @@ -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" @@ -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 diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 676e3ad7c6..8fcaff8dff 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -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 = { @@ -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 { @@ -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] = { diff --git a/packages/opencode/src/command/template/create-hook.txt b/packages/opencode/src/command/template/create-hook.txt new file mode 100644 index 0000000000..b2133ff59a --- /dev/null +++ b/packages/opencode/src/command/template/create-hook.txt @@ -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____`) +- `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____` 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. +``` diff --git a/packages/opencode/src/command/template/import-claude-hooks.txt b/packages/opencode/src/command/template/import-claude-hooks.txt index dd54545f74..48482ff498 100644 --- a/packages/opencode/src/command/template/import-claude-hooks.txt +++ b/packages/opencode/src/command/template/import-claude-hooks.txt @@ -69,10 +69,14 @@ Example migration: // Migrated (hooks.json) { - "PreToolUse": [{ - "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/validate.sh" - }] + "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { "type": "command", "command": "bash ${CLAUDE_PLUGIN_ROOT}/validate.sh" } + ] + } + ] } ``` Note: `${CLAUDE_PLUGIN_ROOT}` is a runtime placeholder that OpenCode will expand based on the hook's location. Since we're moving to `.opencode/`, the expansion will now point there. @@ -84,19 +88,23 @@ Approved hooks go to: - **Global scope** (from `~/.claude/settings.json`): `~/.config/opencode/hooks.json` - **Project scope** (from `.claude/settings.json` or `.claude/settings.local.json`): `.opencode/hooks.json` -Format: top-level event keys (NOT wrapped in `{"hooks": {...}}`). Example: +Format: top-level event keys (NOT wrapped in `{"hooks": {...}}`). Each event maps to an array of matcher blocks; each block wraps an optional `matcher` and a `hooks` array: + ```json { "PreToolUse": [ { - "type": "command", - "command": "bash -c \"echo pre-tool\"" + "matcher": "*", + "hooks": [ + { "type": "command", "command": "bash -c \"echo pre-tool\"" } + ] } ], "PostToolUse": [ { - "type": "http", - "url": "https://api.example.com/hook" + "hooks": [ + { "type": "http", "url": "https://api.example.com/hook" } + ] } ] } @@ -104,27 +112,7 @@ Format: top-level event keys (NOT wrapped in `{"hooks": {...}}`). Example: If the target file already exists, merge (append) the imported hooks rather than overwriting. -### 5. Update AGENTS.md - -Scan the project's `AGENTS.md` for the managed section: - -```markdown - -## Configured Hooks - -### Global Hooks -- **PreToolUse**: `bash -c "echo pre-tool"` (command) - -### Project Hooks -- **PostToolUse**: `https://api.example.com/hook` (http) - -``` - -If the markers don't exist, add them at the end of AGENTS.md with a summary of imported hooks. If they exist, replace the content between them while preserving everything outside. - -The summary should be brief (event + type + one-line description per hook). - -### 6. Report +### 5. Report After migration, report: @@ -133,32 +121,34 @@ After migration, report: - Any hooks that were edited during migration - Deprecation warnings (if hooks were found in .opencode/settings.json) - Reminder: "You can now safely delete .claude/ directories if you no longer use Claude Code with this project." +- The imported hooks are now visible in the **Active Hooks** block of the system prompt (rendered dynamically each turn from the live `hooks.json` state) — no manual AGENTS.md update is needed. ## Hooks.json Format Reference -OpenCode's `hooks.json` uses top-level event keys. Supported events: - -- `PreToolUse`, `PostToolUse` -- `SessionStart`, `SessionEnd` -- `UserPromptSubmit` -- `Stop`, `StopFailure` -- `SubagentStart`, `SubagentStop` -- `TaskCreated`, `TaskCompleted` -- `PermissionRequest`, `PermissionDenied` -- `FileChanged` -- `PreCompact`, `PostCompact` -- `WorktreeCreate`, `WorktreeRemove` -- `ConfigChange` -- `TeammateIdle` -- `InstructionsLoaded` - -Each event maps to an array of hook matchers. Each matcher can have: -- `type`: "command" | "http" | "prompt" | "agent" -- `command`/`url`/`prompt`: the hook action -- `timeout`: optional seconds -- `matcher`: optional regex pattern (tool name matching) - -Merge semantics: concat-append (hooks accumulate, not replace). +OpenCode's `hooks.json` uses top-level event keys (27 events total). For the canonical list with per-event input/output shapes, see the `configure-hooks` skill. + +Supported events: + +- Tool lifecycle: `PreToolUse`, `PostToolUse`, `PostToolUseFailure` +- Permission: `PermissionRequest`, `PermissionDenied` +- Session lifecycle: `Setup`, `SessionStart`, `SessionEnd`, `Stop`, `StopFailure` +- Subagents: `SubagentStart`, `SubagentStop` +- Prompt/compaction: `UserPromptSubmit`, `PreCompact`, `PostCompact` +- Tasks/goals: `TaskCreated`, `TaskCompleted`, `TeammateIdle` +- Other: `Notification`, `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` + +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 +- `hooks`: array of hook entries + +Each hook entry has: + +- `type`: `"command"` | `"mcp"` | `"http"` | `"prompt"` | `"agent"` +- `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) + +Merge semantics: concat-append (hooks accumulate across layers and within a file, not replace). ## Important Notes diff --git a/packages/opencode/src/hook/extensions/hot-reload.ts b/packages/opencode/src/hook/extensions/hot-reload.ts index c799e38704..8780966051 100644 --- a/packages/opencode/src/hook/extensions/hot-reload.ts +++ b/packages/opencode/src/hook/extensions/hot-reload.ts @@ -132,16 +132,15 @@ export function watchSettings( const check = () => { if (closed) return - // Detect both modification (mtime increased) and deletion (mtime went from - // non-zero to 0). Either triggers a reload since deleting hooks.json should - // unload those hooks. Update mtime snapshots and schedule a single reload - // — reload() re-reads the whole chain (loadChain handles missing files). + // Detect any mtime change (increase, decrease, or deletion → 0) and trigger + // a reload. reload() re-reads the whole chain (loadChain handles missing + // files) and is idempotent, so treating any change uniformly is safe — + // including cp -p / touch -t restoring an older timestamp. let changedFile: string | undefined for (const f of files) { const m = mtimeOrZero(f) const prev = mtimes.get(f) ?? 0 - // Detect: file modified (mtime increased) OR file deleted (mtime went from >0 to 0) - if (m > prev || (prev > 0 && m === 0)) { + if (m !== prev) { mtimes.set(f, m) changedFile = f } diff --git a/packages/opencode/src/hook/settings.ts b/packages/opencode/src/hook/settings.ts index ab61db7227..94fc0d5905 100644 --- a/packages/opencode/src/hook/settings.ts +++ b/packages/opencode/src/hook/settings.ts @@ -200,6 +200,20 @@ export interface Settings { allowUntrusted?: boolean } +/** + * Read-only render DTO for the dynamic Active Hooks system-prompt block. + * One entry per individual hook command across the merged chain, tagged with + * the layer it came from. Produced by `summarizeChain` and surfaced via + * `SettingsHook.list()` — never re-reads files (reads from hot-reloaded state). + */ +export interface HookSummary { + event: HookEvent + scope: "global" | "project" | "worktree" + type: HookCommand["type"] + descriptor: string + matcher?: string +} + export interface HookJSONOutput { continue?: boolean stopReason?: string @@ -539,6 +553,27 @@ function promptText(entry: HookCommand): string { return entry.prompt ?? entry.command ?? "" } +/** + * Short human-readable description of a hook entry for the Active Hooks block. + * All types are uniformly truncated to 60 chars: command → command text, + * http → URL, mcp → tool name, prompt/agent → first line of the prompt/goal. + */ +function descriptorFor(entry: HookCommand): string { + switch (entry.type) { + case "command": + return commandText(entry).slice(0, 60) + case "http": + return httpUrl(entry).slice(0, 60) + case "mcp": + return commandText(entry).slice(0, 60) + case "prompt": + case "agent": + return promptText(entry).split("\n")[0].slice(0, 60) + default: + return commandText(entry).slice(0, 60) + } +} + // ── Matcher ───────────────────────────────────────────────────── /** @@ -640,38 +675,79 @@ export function mergeSettings(layers: Settings[]): Settings { return out } +/** + * Resolve the OpenCode global config directory. Uses the explicit override + * when provided (tests), otherwise falls back to `Global.Path.config` with a + * `~/.config/opencode` default. Shared by chainCandidates and loadChain so + * the fallback logic exists in exactly one place. + */ +function resolveGlobalConfig(globalConfig?: string): string { + if (globalConfig) return globalConfig + try { + return Global.Path.config + } catch { + return path.join(os.homedir(), ".config", "opencode") + } +} + +/** + * Build the hooks.json candidate file list with scope tags. Shared by + * loadChain (merge) and summarizeChain (scope-tagged summaries) so adding or + * removing a path layer updates both consumers without a second edit. + */ +function chainCandidates( + directory: string, + worktree: string, + globalConfig?: string, +): Array<{ scope: "global" | "project" | "worktree"; file: string }> { + const opencodeGlobal = resolveGlobalConfig(globalConfig) + const candidates: Array<{ scope: "global" | "project" | "worktree"; file: string }> = [ + { scope: "global", file: path.join(opencodeGlobal, "hooks.json") }, + { scope: "project", file: path.join(directory, ".opencode", "hooks.json") }, + ] + if (worktree && worktree !== directory) { + candidates.push({ scope: "worktree", file: path.join(worktree, ".opencode", "hooks.json") }) + } + return candidates +} + +/** + * Produce scope-tagged summaries of the merged hooks chain — one entry per + * individual hook command, tagged with the layer (global/project/worktree) it + * came from. Ordering matches loadChain: global first, then project, then + * worktree. Used by `SettingsHook.list()` so the Active Hooks block reflects + * live, hot-reloaded state without re-reading files on every call. + * + * Exported for unit testing only; not part of the public surface. + */ +export function summarizeChain(directory: string, worktree: string, globalConfig?: string): HookSummary[] { + return chainCandidates(directory, worktree, globalConfig).flatMap(({ scope, file }) => { + const data = readJSON(file) + if (!data?.hooks) return [] + return Object.entries(data.hooks).flatMap(([event, matchers]) => + (matchers ?? []).flatMap((m) => + (m.hooks ?? []).map((h) => ({ + event: event as HookEvent, + scope, + type: h.type, + descriptor: descriptorFor(h), + ...(m.matcher && m.matcher !== "*" ? { matcher: m.matcher } : {}), + })), + ), + ) + }) +} + // Exported for unit testing only; not part of the public surface. // `globalConfig` overrides the resolved OpenCode global config dir so tests can // point it at an isolated temp dir instead of the real ~/.config/opencode. export function loadChain(directory: string, worktree: string, globalConfig?: string): Settings { - const home = os.homedir() - // Best-effort OpenCode global path; falls back to ~/.config/opencode. - // Optional globalConfig override is used by tests for deterministic isolation. - const opencodeGlobal = globalConfig ?? (() => { - try { - return Global.Path.config - } catch { - return path.join(home, ".config", "opencode") - } - })() - - // Hooks live in dedicated hooks.json files in OpenCode-owned directories only. - // `.claude/` is not read for hooks (complete cut); `.local` variants are dropped - // (one file per scope). Merge is concat-append (global → project → worktree). - const candidates = [ - path.join(opencodeGlobal, "hooks.json"), - path.join(directory, ".opencode", "hooks.json"), - ] - - // If worktree differs from directory (e.g. git worktree), also check it. - if (worktree && worktree !== directory) { - candidates.push(path.join(worktree, ".opencode", "hooks.json")) - } + const opencodeGlobal = resolveGlobalConfig(globalConfig) - const layers = candidates - .map((fp) => { - const data = readJSON(fp) - if (data) warnUnsupportedFields(data.hooks, path.dirname(fp)) + const layers = chainCandidates(directory, worktree, globalConfig) + .map(({ file }) => { + const data = readJSON(file) + if (data) warnUnsupportedFields(data.hooks, path.dirname(file)) return data }) .filter((s): s is Settings => s !== null) @@ -1127,6 +1203,12 @@ interface State { * the "" bucket, preserving the prior global-dedup behavior for those. */ seen: Map> + /** + * Scope-tagged summaries of the currently-effective hooks, computed by + * `summarizeChain` alongside `settings` (same closure, same hot-reload + * watcher). `list()` reads this without touching files. + */ + hooksList: HookSummary[] } export interface Interface { @@ -1134,6 +1216,13 @@ export interface Interface { payload: HookPayload, ctx: TriggerContext, ) => Effect.Effect + /** + * Read-only view of the currently-effective hooks (merged global + project + + * worktree chain), one entry per hook command tagged with its source layer. + * Backed by the same hot-reloaded state `trigger` consults — never re-reads + * files. Empty when no hooks.json layer defines any hook. + */ + readonly list: () => Effect.Effect> } export class Service extends Context.Service()("@opencode/SettingsHook") {} @@ -1485,6 +1574,7 @@ export const layer = Layer.effect( const settings = loadChain(instCtx.directory, instCtx.worktree) const stateObj = { settings, + hooksList: summarizeChain(instCtx.directory, instCtx.worktree, Global.Path.config), cwd: instCtx.directory, seen: new Map>(), } satisfies State @@ -1496,12 +1586,22 @@ export const layer = Layer.effect( // state object, so the mutation is visible without invalidating the // cache. The finalizer closes the watcher when the instance scope is // disposed (same scope-based cleanup discipline as GoalLoop.state). + // + // The reload Effect computes both merged settings and scope-tagged + // summaries in one pass; lastSummaries carries the summaries into the + // onReload callback (watchSettings only threads Settings through). + let lastSummaries: HookSummary[] = stateObj.hooksList const handle = watchSettings( instCtx.directory, instCtx.worktree, - () => Effect.sync(() => loadChain(instCtx.directory, instCtx.worktree)), + () => Effect.sync(() => { + const newSettings = loadChain(instCtx.directory, instCtx.worktree) + lastSummaries = summarizeChain(instCtx.directory, instCtx.worktree, Global.Path.config) + return newSettings + }), (newSettings) => { stateObj.settings = newSettings + stateObj.hooksList = lastSummaries }, Global.Path.config, ) @@ -1770,7 +1870,12 @@ export const layer = Layer.effect( return result }) - return Service.of({ trigger }) + const list = Effect.fn("SettingsHook.list")(function* () { + const s = yield* InstanceState.get(state) + return s.hooksList + }) + + return Service.of({ trigger, list }) }), ) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c749012492..f8ea0fbcad 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1456,12 +1456,13 @@ export const layer = Layer.effect( yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - const [skills, env, instructions, mcpInstructions, goalDocs, modelMsgs] = yield* Effect.all([ + const [skills, env, instructions, mcpInstructions, goalDocs, hooksDocs, modelMsgs] = yield* Effect.all([ sys.skills(agent), sys.environment(model), instruction.system().pipe(Effect.orDie), sys.mcp(agent, session.permission), sys.goal(sessionID), + sys.hooks(), MessageV2.toModelMessagesEffect(msgs, model), ]) const system = [ @@ -1470,6 +1471,7 @@ export const layer = Layer.effect( ...(mcpInstructions ? [mcpInstructions] : []), ...(skills ? [skills] : []), ...goalDocs, + ...hooksDocs, ] const format = lastUser.format ?? { type: "text" as const } if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index b1ae4b44eb..0e0cdd3b3a 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -25,6 +25,7 @@ import { MCP } from "@/mcp" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Goal } from "@/goal/goal" import { GoalPrompts } from "@/goal/prompts" +import { SettingsHook } from "@/hook/settings" import type { SessionID } from "@/session/schema" export function provider(model: Provider.Model) { @@ -48,6 +49,7 @@ export interface Interface { readonly skills: (agent: Agent.Info) => Effect.Effect readonly mcp: (agent: Agent.Info, permission?: PermissionV1.Ruleset) => Effect.Effect readonly goal: (sessionID: SessionID) => Effect.Effect + readonly hooks: () => Effect.Effect } export class Service extends Context.Service()("@opencode/SystemPrompt") {} @@ -150,6 +152,25 @@ export const layer = Layer.effect( // avoid prompt bloat (spec: no-active-goal-injected-as-terse-note). return [PROMPT_GOAL, GoalPrompts.renderGoalSystemBlock(state)] }), + + // Active Hooks block — dynamic, mirrors goal's economy: no hooks → empty + // array (no header, no placeholder). SettingsHook is resolved at request + // time via serviceOption (per tool-service-resolution spec) so headless / + // test entry points that omit the heavyweight service degrade cleanly. + hooks: Effect.fn("SystemPrompt.hooks")(function* () { + const hookSvc = Option.getOrUndefined(yield* Effect.serviceOption(SettingsHook.Service)) + if (!hookSvc) return [] + const hooks = yield* hookSvc.list() + if (hooks.length === 0) return [] + const MAX = 20 + const shown = hooks.slice(0, MAX) + const lines = [ + "## Active Hooks", + ...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)`) + return [lines.join("\n")] + }), }) }), ) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 50ead1323e..64aba839eb 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -30,8 +30,7 @@ const SKILL_PATTERN = "**/SKILL.md" // when the model is asked to touch opencode's own config files gives it the // actual schemas instead of guesses. const CUSTOMIZE_OPENCODE_SKILL_NAME = "customize-opencode" -const CUSTOMIZE_OPENCODE_SKILL_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, 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." +const CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION = SkillPlugin.CustomizeOpencodeDescription const CUSTOMIZE_OPENCODE_SKILL_BODY = SkillPlugin.CustomizeOpencodeContent // Built-in skill. Agents have no innate knowledge of opencode's hooks system @@ -40,8 +39,7 @@ const CUSTOMIZE_OPENCODE_SKILL_BODY = SkillPlugin.CustomizeOpencodeContent // know hooks are possible and when to reach for them; the body (loaded lazily // on skill invocation) has the event list, file format, and handler protocol. const CONFIGURE_HOOKS_SKILL_NAME = "configure-hooks" -const CONFIGURE_HOOKS_SKILL_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." +const CONFIGURE_HOOKS_SKILL_DESCRIPTION = SkillPlugin.ConfigureHooksDescription const CONFIGURE_HOOKS_SKILL_BODY = SkillPlugin.ConfigureHooksContent export const Info = Schema.Struct({ diff --git a/packages/opencode/test/hook/list.test.ts b/packages/opencode/test/hook/list.test.ts new file mode 100644 index 0000000000..cb89cdfa5f --- /dev/null +++ b/packages/opencode/test/hook/list.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, test } from "bun:test" +import * as fs from "fs/promises" +import os from "os" +import path from "path" +import { summarizeChain } from "@/hook/settings" + +// Unit tests for summarizeChain — the scope-tagged read surface behind +// SettingsHook.list(). Mirrors load-chain.test.ts: isolated temp dirs for the +// global / project / worktree scopes, globalConfig override for determinism. + +async function mktmp(prefix: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), `hook-list-${prefix}-`)) +} + +async function writeHooksJson(dir: string, json: unknown): Promise { + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + await fs.writeFile(path.join(opencodeDir, "hooks.json"), JSON.stringify(json)) +} + +async function writeGlobalHooksJson(globalDir: string, json: unknown): Promise { + await fs.mkdir(globalDir, { recursive: true }) + await fs.writeFile(path.join(globalDir, "hooks.json"), JSON.stringify(json)) +} + +describe("summarizeChain — scope tags and ordering", () => { + test("global entries come before project entries with correct scope tags", async () => { + const globalDir = await mktmp("global") + const projectDir = await mktmp("project") + try { + await writeGlobalHooksJson(globalDir, { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "global-check.sh" }] }], + }) + await writeHooksJson(projectDir, { + Stop: [{ hooks: [{ type: "command", command: "project-stop.sh" }] }], + }) + + const summaries = summarizeChain(projectDir, "", globalDir) + expect(summaries.length).toBe(2) + // Global layer appended first, project after — same order loadChain merges. + expect(summaries[0].scope).toBe("global") + expect(summaries[0].event).toBe("PreToolUse") + expect(summaries[1].scope).toBe("project") + expect(summaries[1].event).toBe("Stop") + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true })]) + } + }) + + test("no hooks.json layers → empty summary", async () => { + const globalDir = await mktmp("empty-g") + const projectDir = await mktmp("empty-p") + try { + const summaries = summarizeChain(projectDir, "", globalDir) + expect(summaries).toEqual([]) + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true })]) + } + }) + + test("worktree layer appends after project when worktree differs", async () => { + const globalDir = await mktmp("wt-g") + const projectDir = await mktmp("wt-p") + const worktreeDir = await mktmp("wt-w") + try { + await writeHooksJson(projectDir, { Stop: [{ hooks: [{ type: "command", command: "p" }] }] }) + await writeHooksJson(worktreeDir, { Stop: [{ hooks: [{ type: "command", command: "w" }] }] }) + + const summaries = summarizeChain(projectDir, worktreeDir, globalDir) + expect(summaries.map((s) => s.scope)).toEqual(["project", "worktree"]) + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true }), fs.rm(worktreeDir, { recursive: true, force: true })]) + } + }) +}) + +describe("summarizeChain — descriptor derivation by type", () => { + test("command descriptor is the command text", async () => { + const projectDir = await mktmp("desc-cmd") + try { + await writeHooksJson(projectDir, { + PreToolUse: [{ hooks: [{ type: "command", command: "echo hello world" }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor).toBe("echo hello world") + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("command descriptor truncates to 60 chars", async () => { + const longCommand = "x".repeat(100) + const projectDir = await mktmp("desc-long") + try { + await writeHooksJson(projectDir, { + PreToolUse: [{ hooks: [{ type: "command", command: longCommand }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor.length).toBe(60) + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("http descriptor is the url", async () => { + const projectDir = await mktmp("desc-http") + try { + await writeHooksJson(projectDir, { + PostToolUse: [{ hooks: [{ type: "http", url: "https://example.com/webhook" }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor).toBe("https://example.com/webhook") + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("http descriptor truncates long urls to 60 chars", async () => { + const longUrl = `https://example.com/${"x".repeat(80)}` + const projectDir = await mktmp("desc-http-long") + try { + await writeHooksJson(projectDir, { + PostToolUse: [{ hooks: [{ type: "http", url: longUrl }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor.length).toBe(60) + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("mcp descriptor is the tool name (command field)", async () => { + const projectDir = await mktmp("desc-mcp") + try { + await writeHooksJson(projectDir, { + PreToolUse: [{ hooks: [{ type: "mcp", command: "my-server__my-tool" }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor).toBe("my-server__my-tool") + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("prompt descriptor is the first line of the prompt", async () => { + const projectDir = await mktmp("desc-prompt") + try { + await writeHooksJson(projectDir, { + Stop: [{ hooks: [{ type: "prompt", prompt: "Summarize the work done.\nMore detail here." }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor).toBe("Summarize the work done.") + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("prompt descriptor truncates long first lines to 60 chars", async () => { + const longPrompt = `${"a".repeat(80)}\nsecond line` + const projectDir = await mktmp("desc-prompt-long") + try { + await writeHooksJson(projectDir, { + Stop: [{ hooks: [{ type: "prompt", prompt: longPrompt }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor.length).toBe(60) + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("agent descriptor is the first line of the goal", async () => { + const projectDir = await mktmp("desc-agent") + try { + await writeHooksJson(projectDir, { + Stop: [{ hooks: [{ type: "agent", prompt: "Run a final review.\nCheck tests too." }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor).toBe("Run a final review.") + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) +}) + +describe("summarizeChain — matcher handling", () => { + test("specific matcher is included in summary", async () => { + const projectDir = await mktmp("match-specific") + try { + await writeHooksJson(projectDir, { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "check.sh" }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.matcher).toBe("Bash") + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("wildcard matcher (*) is omitted", async () => { + const projectDir = await mktmp("match-wild") + try { + await writeHooksJson(projectDir, { + Stop: [{ matcher: "*", hooks: [{ type: "command", command: "stop.sh" }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.matcher).toBeUndefined() + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("absent matcher is omitted", async () => { + const projectDir = await mktmp("match-absent") + try { + await writeHooksJson(projectDir, { + Stop: [{ hooks: [{ type: "command", command: "stop.sh" }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.matcher).toBeUndefined() + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/opencode/test/hook/settings-hot-reload.test.ts b/packages/opencode/test/hook/settings-hot-reload.test.ts index 02671dcea8..136f7ffbb2 100644 --- a/packages/opencode/test/hook/settings-hot-reload.test.ts +++ b/packages/opencode/test/hook/settings-hot-reload.test.ts @@ -140,4 +140,37 @@ describe("SettingsHook hot-reload — watchSettings wiring (F3)", () => { await fs.rm(dir, { recursive: true, force: true }) }, 15000) + + test("watchSettings reloads when mtime decreases (cp -p / touch -t)", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "hot-reload-mtime-dec-")) + const file = settingsPath(dir) + await fs.mkdir(opencodeDir(dir), { recursive: true }) + await fs.writeFile(file, JSON.stringify({})) + + let marker: string | undefined + const handle = watchSettings( + dir, + undefined, + () => Effect.sync(() => ({ hooks: {} })), + () => { + marker = "reloaded" + }, + ) + + // Wait for the construction-time mtime snapshot to be captured. + await sleep(50) + + // Overwrite the file, then rewind its mtime to 60s ago — simulates + // `cp -p` from a backup or `touch -t`. The mtime is now LOWER than the + // snapshot the watcher took at construction. + await fs.writeFile(file, JSON.stringify({ Stop: [] })) + const oldTime = new Date(Date.now() - 60_000) + await fs.utimes(file, oldTime, oldTime) + + await sleep(3000) + expect(marker).toBe("reloaded") + + handle.close() + await fs.rm(dir, { recursive: true, force: true }) + }, 15000) }) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index ce3e0a07b5..26529f9f0e 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -20,6 +20,7 @@ const noopSettingsHook = Layer.succeed( SettingsHook.Service, SettingsHook.Service.of({ trigger: () => Effect.succeed({ blocked: undefined, permissionDecision: undefined, permissionDecisionReason: undefined, additionalContexts: [], systemMessages: [], hookSpecificOutput: undefined }), + list: () => Effect.succeed([]), }), ) diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 548f0028c5..66bf6f88f7 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -1,11 +1,17 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" +import * as fs from "fs/promises" +import path from "path" import type { Agent } from "../../src/agent/agent" import { NamedError } from "@opencode-ai/core/util/error" import { Skill } from "../../src/skill" import { Permission } from "../../src/permission" import { SystemPrompt } from "../../src/session/system" import { MCP } from "../../src/mcp" +import { SettingsHook, type HookSummary } from "../../src/hook/settings" +import { SessionHooks } from "../../src/hook/session-hooks" +import { EventV2Bridge } from "../../src/event-v2-bridge" +import { Database } from "@opencode-ai/core/database/database" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { testEffect } from "../lib/effect" @@ -138,4 +144,130 @@ describe("session.system", () => { ) }), ) + + it.effect("hooks block renders Active Hooks when SettingsHook provides entries", () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const entries: HookSummary[] = [ + { 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) }), + ), + ) + + expect(output).toEqual([ + [ + "## Active Hooks", + "- PreToolUse [project/command] echo hi", + "- Stop [global/http] https://example.com/stop", + ].join("\n"), + ]) + }), + ) + + 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([]) }), + ), + ) + + expect(output).toEqual([]) + }), + ) + + it.effect("hooks block is empty when SettingsHook service is absent (degrades cleanly)", () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + // No SettingsHook layer provided — serviceOption returns None. + const output = yield* prompt.hooks() + + expect(output).toEqual([]) + }), + ) + + it.effect("hooks block caps at 20 entries and reports the remainder", () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const entries: HookSummary[] = Array.from({ length: 25 }, (_, i) => ({ + event: "PreToolUse" as const, + scope: "project" as const, + type: "command" as const, + descriptor: `cmd-${i}`, + })) + 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") + expect(block).toContain("cmd-0") + expect(block).not.toContain("cmd-24") + expect(block).toContain("… and 5 more (see hooks.json)") + }), + ) +}) + +// Integration layer: real SettingsHook (not mocked) + real SystemPrompt. +// Proves the full chain: InstanceState → loadChain → summarizeChain → list() +// → serviceOption resolution in sys.hooks() — the "silent no-op" failure mode +// AGENTS.md warns about when a .node dep is missing. +const integrationLayer = Layer.mergeAll( + SystemPrompt.layer.pipe( + Layer.provide(LocationServiceMap.layer), + Layer.provide(Layer.mock(MCP.Service, { instructions: () => Effect.succeed([]) })), + Layer.provide( + Layer.succeed( + Skill.Service, + Skill.Service.of({ + get: () => Effect.succeed(undefined), + require: (name) => Effect.fail(new Skill.NotFoundError({ name, available: [] })), + all: () => Effect.succeed(skills), + dirs: () => Effect.succeed([]), + available: () => Effect.succeed(skills), + }), + ), + ), + ), + SettingsHook.layer.pipe( + Layer.provide(EventV2Bridge.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provideMerge(SessionHooks.defaultLayer), + ), +) + +const itIntegration = testEffect(integrationLayer) + +describe("session.system integration (real SettingsHook)", () => { + itIntegration.instance( + "hooks block renders with real SettingsHook layer via serviceOption", + () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const output = yield* prompt.hooks() + expect(output.length).toBe(1) + expect(output[0]).toContain("## Active Hooks") + expect(output[0]).toContain("echo integration-test") + }), + { + init: (dir) => + Effect.promise(async () => { + const opencode = path.join(dir, ".opencode") + await fs.mkdir(opencode, { recursive: true }) + await fs.writeFile( + path.join(opencode, "hooks.json"), + JSON.stringify({ + Stop: [{ hooks: [{ type: "command", command: "echo integration-test" }] }], + }), + ) + }), + }, + ) })