diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 5aaccc1519..26149785da 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -70,6 +70,11 @@ jobs: - name: Setup Bun uses: ./.github/actions/setup-bun + with: + # ci-typecheck.yml's Linux job is the designated Linux cache + # saver (runs on every push, fast, low collision risk) โ€” this job + # only restores, to avoid racing on the same {OS}-bun-{hash} key. + save-cache: false - name: Configure Git Identity run: | diff --git a/.github/workflows/release-fork.yml b/.github/workflows/release-fork.yml index 5e3c59f4ac..50ad2b689e 100644 --- a/.github/workflows/release-fork.yml +++ b/.github/workflows/release-fork.yml @@ -87,6 +87,13 @@ jobs: - name: Setup Bun if: inputs.platforms == '' || contains(inputs.platforms, matrix.name) uses: ./.github/actions/setup-bun + with: + # Linux and Windows bun caches are each owned by a single saver + # elsewhere (ci-typecheck.yml linux, ci-test.yml e2e windows) to + # avoid racing on the same {OS}-bun-{hash} key when this manual + # release build runs concurrently with a push-triggered CI run. + # macOS has no other job in the repo, so it's safe to save here. + save-cache: ${{ matrix.name == 'macos' }} - name: Build CLI if: inputs.platforms == '' || contains(inputs.platforms, matrix.name) diff --git a/.gitignore b/.gitignore index dc9d763f83..d32e093a55 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ logs/ tsconfig.tsbuildinfo # opencode runtime-generated local project config (created when running the built binary in a package dir) **/.opencode/settings.json + +# OpenSpec artifacts (local-only, not tracked) +/openspec/ diff --git a/README.md b/README.md index 0ffbd0b272..d90776d313 100644 --- a/README.md +++ b/README.md @@ -55,11 +55,11 @@ Built on top of the MIT-licensed [opencode](https://github.com/anomalyco/opencod ### ๐Ÿ“Œ Stable on `main` -#### Hooks API (22 events ร— 5 execution types) +#### Hooks API (27 events ร— 5 execution types) -Full Claude Code hooks protocol compatibility: `command`, `mcp`, `http`, `prompt`, `agent` hook types with 17 hook events including `PreToolUse`, `PostToolUse`, `SessionStart`, `PermissionRequest`, `WorktreeCreate`, and more. +Full Claude Code hooks protocol compatibility: `command`, `mcp`, `http`, `prompt`, `agent` hook types with 27 hook events including `PreToolUse`, `PostToolUse`, `SessionStart`, `PermissionRequest`, `WorktreeCreate`, and more. -See [hooks reference](./packages/opencode/src/session/prompt/hooks-reference.md). +See [hooks reference](./packages/core/src/plugin/skill/configure-hooks.md). #### Goal Auto-Loop diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index ea723dd89d..5a8bc85760 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -7,8 +7,16 @@ import { Effect } from "effect" import { AbsolutePath } from "../schema" import { SkillV2 } from "../skill" import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" } +import configureHooksContent from "./skill/configure-hooks.md" with { type: "text" } export const CustomizeOpencodeContent = customizeOpencodeContent +export const ConfigureHooksContent = configureHooksContent + +export const CustomizeOpencodeDescription = + "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." + +export const ConfigureHooksDescription = + "Use when the user wants to automatically run something on an opencode event โ€” before/after a tool call, on session start/end, on compaction, etc. โ€” or asks about opencode's hooks / hooks.json / event hooks. Covers hooks.json file locations and format, the 27 supported events, and the 5 hook types (command, mcp, http, prompt, agent). Also use to migrate hooks from Claude Code's .claude/settings.json via /import-claude-hooks." export const Plugin = define({ id: "skill", @@ -19,13 +27,23 @@ export const Plugin = define({ type: "embedded", skill: SkillV2.Info.make({ name: "customize-opencode", - description: - "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.", + description: CustomizeOpencodeDescription, location: AbsolutePath.make("/builtin/customize-opencode.md"), content: CustomizeOpencodeContent, }), }), ) + draft.source( + SkillV2.EmbeddedSource.make({ + type: "embedded", + skill: SkillV2.Info.make({ + name: "configure-hooks", + description: ConfigureHooksDescription, + location: AbsolutePath.make("/builtin/configure-hooks.md"), + content: ConfigureHooksContent, + }), + }), + ) }) }), }) diff --git a/packages/core/src/plugin/skill/configure-hooks.md b/packages/core/src/plugin/skill/configure-hooks.md new file mode 100644 index 0000000000..453b689692 --- /dev/null +++ b/packages/core/src/plugin/skill/configure-hooks.md @@ -0,0 +1,123 @@ + + +# Configuring OpenCode hooks + +Hooks let you run code (shell command, MCP tool, HTTP call, LLM prompt, or an +autonomous sub-agent) automatically when specific events happen during a +session โ€” before/after a tool call, on session start, on compaction, etc. +Config lives in dedicated `hooks.json` files (NOT `opencode.json`, NOT +`.claude/settings.json` โ€” `.claude/` is never read for hooks). + +## Where files live + +| Scope | Path | Hot-reloaded? | +| ------- | ------------------------------------ | --------------------------------- | +| Global | `~/.config/opencode/hooks.json` | No โ€” requires restart | +| Project | `.opencode/hooks.json` | Yes โ€” polled every ~2s | +| Worktree| `/.opencode/hooks.json` | Yes (when worktree โ‰  project dir) | + +Layers concat-append (do NOT override by key): global hooks run, then project +hooks are appended after, in file order. A single event can have hooks from +multiple layers all firing. + +If you find a `hooks` field left inside `settings.json` or `.claude/`, it is +ignored โ€” point the user at `/import-claude-hooks` to migrate it. + +## File format + +Top-level keys are event names; each maps to a list of matcher blocks: + +```json +{ + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{ "type": "command", "command": "./scripts/check.sh", "timeout": 10 }] + } + ], + "SessionStart": [ + { + "matcher": "*", + "hooks": [{ "type": "command", "command": "./scripts/welcome.sh" }] + } + ] +} +``` + +`matcher` selects which tool/target the block applies to: + +- `"*"` or omitted โ€” matches everything +- `"Bash"` โ€” exact match (case-insensitive) +- `"Bash|Edit|Write"` โ€” pipe-separated list +- any other string โ€” treated as a regex tested against the target + +## Events (27 total) + +Tool lifecycle: `PreToolUse`, `PostToolUse`, `PostToolUseFailure` +Permission: `PermissionRequest`, `PermissionDenied` +Session lifecycle: `Setup`, `SessionStart`, `SessionEnd`, `Stop`, `StopFailure` +Subagents: `SubagentStart`, `SubagentStop` +Prompt/compaction: `UserPromptSubmit`, `PreCompact`, `PostCompact` +Tasks/goals: `TaskCreated`, `TaskCompleted`, `TeammateIdle` +Other: `Notification`, `Elicitation`, `ElicitationResult`, `ConfigChange`, +`WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, +`FileChanged` + +If you need the exact input/output shape for a specific event, read +`packages/opencode/src/hook/settings.ts` (`HookEvent`, `HookSpecificOutput`) โ€” +this skill is a map, not the full schema. + +## Hook types (all 5 implemented) + +| `type` | What it does | +| --------- | ----------------------------------------------------------------------------- | +| `command` | Runs a shell command. Event data is piped to stdin as JSON; stdout/exit code drive the result. | +| `mcp` | Invokes an MCP tool, addressed as `mcp____`. | +| `http` | POSTs the event envelope to `url`; response body is parsed as JSON. | +| `prompt` | Sends the event to an LLM, constrained to structured JSON output. | +| `agent` | Runs an autonomous sub-agent loop (bash/read_file/list_dir/grep) to react to the event. | + +### `command` protocol + +- stdin: JSON envelope with event data +- exit code `0`: success, stdout optionally parsed as `HookJSONOutput` JSON +- exit code `2`: **block** โ€” stderr becomes the block reason, shown to the agent +- any other exit code, or timeout: logged as a warning, does NOT abort the flow +- `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PLUGIN_DATA}` expand to the directory the + hook was declared in / its data dir โ€” usable in `command` +- `options` (fork-only field, no CC equivalent): exported as + `CLAUDE_PLUGIN_OPTION_` env vars in the subprocess + +### Common output fields (`HookJSONOutput`, applies across types) + +```json +{ + "decision": "approve" | "block", + "reason": "shown when blocking", + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow" | "deny" | "ask", + "additionalContext": "text injected into the session" + } +} +``` + +Not every field applies to every event โ€” `hookSpecificOutput` shape varies per +event (see `HookSpecificOutput` in `settings.ts` for the exact per-event union). + +## Applying changes + +Global `hooks.json` loads once at startup โ€” **restart required**. Project and +worktree `hooks.json` are polled and take effect within a few seconds without +a restart. + +## Migrating from Claude Code + +`/import-claude-hooks` reads `~/.claude/settings.json` / `./.claude/settings.json` +/ `.claude/settings.local.json`, walks the user through importing each hook, +and writes approved ones into the right `hooks.json`. Point users here instead +of hand-copying Claude Code hook config. 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/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index 19ce82d81a..2b1458054b 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -30,4 +30,18 @@ describe("SkillPlugin.Plugin", () => { ) }), ) + + it.effect("registers the built-in configure-hooks skill", () => + Effect.gen(function* () { + const skill = yield* SkillV2.Service + yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } })) + + expect(yield* skill.list()).toContainEqual( + expect.objectContaining({ + name: "configure-hooks", + description: expect.stringContaining("hooks"), + }), + ) + }), + ) }) 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 b1c6e7b111..8fcaff8dff 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -8,6 +8,8 @@ import { MCP } from "../mcp" import { Skill } from "../skill" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" +import PROMPT_IMPORT_HOOKS from "./template/import-claude-hooks.txt" +import PROMPT_CREATE_HOOK from "./template/create-hook.txt" import { LegacyEvent } from "@opencode-ai/schema/legacy-event" type State = { @@ -47,6 +49,8 @@ export const Default = { REVIEW: "review", GOAL: "goal", SUBGOAL: "subgoal", + IMPORT_HOOKS: "import-claude-hooks", + CREATE_HOOK: "create-hook", } as const export interface Interface { @@ -101,6 +105,22 @@ export const layer = Layer.effect( template: "", hints: ["$ARGUMENTS"], } + commands[Default.IMPORT_HOOKS] = { + name: Default.IMPORT_HOOKS, + description: "Import hooks from Claude Code config to OpenCode hooks.json", + source: "command", + template: PROMPT_IMPORT_HOOKS, + subtask: true, + hints: [], + } + commands[Default.CREATE_HOOK] = { + name: Default.CREATE_HOOK, + description: "Create a new hook interactively and write it to hooks.json", + source: "command", + template: PROMPT_CREATE_HOOK, + subtask: true, + hints: [], + } for (const [name, command] of Object.entries(cfg.command ?? {})) { commands[name] = { 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 new file mode 100644 index 0000000000..48482ff498 --- /dev/null +++ b/packages/opencode/src/command/template/import-claude-hooks.txt @@ -0,0 +1,158 @@ +--- +description: Import hooks from Claude Code config to OpenCode hooks.json +--- + +# Import Claude Hooks + +This command migrates hook configurations from Claude Code's `.claude/settings.json` files to OpenCode's dedicated `hooks.json` format. OpenCode no longer reads `.claude/` directories โ€” this provides a one-time migration path. + +## Process + +### 1. Scan for Claude hooks + +Read these files and detect any `hooks` field: + +- `~/.claude/settings.json` (global Claude config) +- `.claude/settings.json` (project-level) +- `.claude/settings.local.json` (project-local, if exists) + +Also scan OpenCode's legacy locations for deprecation warnings: + +- `.opencode/settings.json` +- `~/.config/opencode/settings.json` + +If a `hooks` field is found in OpenCode's settings.json files, log a warning: +``` +โš ๏ธ hooks field found in โ€” hooks are now loaded from hooks.json. Run /import-claude-hooks to migrate. +``` +These are NOT imported (only .claude/ sources are imported). + +### 2. Parse and present hooks + +For each detected hook entry, present it to the user for review: + +```markdown +### PreToolUse hook (from .claude/settings.json) + +**Type**: command +**Matcher**: (empty = all tools) +**Command**: `bash -c "echo pre-tool"` +**Timeout**: 30s + +Import this hook? [y/n/edit] +``` + +Ask the user for each hook: +- **y** = import as-is +- **n** = skip +- **edit** = allow user to modify before importing + +### 3. Handle path migration + +When importing, fix any hardcoded `.claude/` paths: + +- 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/` + +Example migration: +```json +// Original (.claude/settings.json) +{ + "hooks": { + "PreToolUse": [{ + "type": "command", + "command": "bash ${CLAUDE_PLUGIN_ROOT}/validate.sh" + }] + } +} + +// Migrated (hooks.json) +{ + "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. + +### 4. Write to hooks.json + +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": {...}}`). Each event maps to an array of matcher blocks; each block wraps an optional `matcher` and a `hooks` array: + +```json +{ + "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { "type": "command", "command": "bash -c \"echo pre-tool\"" } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { "type": "http", "url": "https://api.example.com/hook" } + ] + } + ] +} +``` + +If the target file already exists, merge (append) the imported hooks rather than overwriting. + +### 5. Report + +After migration, report: + +- Number of hooks imported (by scope: global/project) +- Number of hooks skipped +- 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 (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 + +- `.claude/` directories are **never** read by OpenCode. This command is the only way to migrate. +- The `hooks` field in `.opencode/settings.json` is deprecated and ignored (deprecation warning logged). +- Hot-reload only watches `.opencode/hooks.json` (project + worktree). Global `~/.config/opencode/hooks.json` requires restart to apply changes. +- Hooks are merge-append, not override. Importing duplicate events will add to the list. diff --git a/packages/opencode/src/hook/extensions/hot-reload.ts b/packages/opencode/src/hook/extensions/hot-reload.ts index ddb69b787d..8780966051 100644 --- a/packages/opencode/src/hook/extensions/hot-reload.ts +++ b/packages/opencode/src/hook/extensions/hot-reload.ts @@ -1,20 +1,27 @@ /** - * [FORK:hook-ext] Settings file hot reload โ€” not in upstream + * [FORK:hook-ext] Hooks config hot reload โ€” not in upstream * - * Watches all settings files in the 6-layer chain for changes and - * triggers a reload when modifications are detected. + * Polls the project (and worktree) `.opencode/hooks.json` files for mtime + * changes and triggers a reload when a modification is detected. * - * Uses Node.js fs.watch (not @parcel/watcher) because: - * 1. Settings files are few and stable โ€” no need for recursive watching - * 2. fs.watch is simpler and has lower overhead for individual files - * 3. Avoids coupling to the FileWatcher service lifecycle + * Scope: project + worktree ONLY. The global `~/.config/opencode/hooks.json` is + * loaded once at startup and NOT polled โ€” global hooks change rarely; changing + * them requires a restart. `.claude/` directories are never read. * - * Debounce: 500ms. Settings edits are human-driven; no need for - * sub-second responsiveness. Prevents double-fire from save-as-you-type - * editors. + * Strategy: interval polling (mtime check every POLL_INTERVAL_MS), not fs.watch. + * Rationale: inotify events are unreliable on WSL2 DrvFs mounts (`/mnt/*`) and + * network filesystems; polling one small file per interval is cheap and + * deterministic (D5). + * + * Debounce: 500ms + min 1s between reloads (kept from the prior fs.watch + * implementation โ€” prevents reload storms on rapid saves). + * + * The watchSettings signature + HotReloadHandle return type are unchanged from + * the prior fs.watch version; only the internal detection mechanism switched to + * polling. */ -import { watch, type FSWatcher, existsSync } from "fs" +import { statSync } from "fs" import path from "path" import { Effect } from "effect" import * as Log from "@/util/log" @@ -22,34 +29,23 @@ import type { Settings } from "../settings" const log = Log.create({ service: "hook.extensions.hot-reload" }) +/** Polling interval (mtime check). Hardcoded constant for now (D5/Q1). */ +const POLL_INTERVAL_MS = 2000 + /** - * Directories whose `settings.json` / `settings.local.json` participate in the - * hook chain. We watch the parent directories (not the individual files) so a - * settings file that does not exist yet is still detected the moment it is - * created โ€” watching the file directly would miss it entirely. Mirrors the - * 6-layer chain in settings.ts loadChain(). + * hooks.json files polled for changes: project + worktree only. Global and + * `.claude/` are excluded โ€” global is startup-only, `.claude/` is never read. */ -function settingsDirs( - projectDir: string, - worktree: string | undefined, - opencodeGlobalConfig?: string, -): string[] { - const home = process.env.HOME ?? process.env.USERPROFILE ?? "" - const dirs = [ - path.join(home, ".claude"), - path.join(projectDir, ".claude"), - path.join(projectDir, ".opencode"), - ] - if (opencodeGlobalConfig) dirs.push(opencodeGlobalConfig) +function watchedFiles(projectDir: string, worktree: string | undefined): string[] { + const files = [path.join(projectDir, ".opencode", "hooks.json")] if (worktree && worktree !== projectDir) { - dirs.push(path.join(worktree, ".claude"), path.join(worktree, ".opencode")) + files.push(path.join(worktree, ".opencode", "hooks.json")) } - // Dedupe (worktree may collapse onto project) and keep only existing dirs. - return [...new Set(dirs)].filter((d) => existsSync(d)) + return files } export interface HotReloadHandle { - /** Stop watching all files */ + /** Stop polling all files */ close(): void } @@ -68,80 +64,102 @@ function countHooks(settings: Settings): number { return count } +/** Current mtimeMs of a file, or 0 when it does not exist (treated as unchanged). */ +function mtimeOrZero(file: string): number { + try { + return statSync(file).mtimeMs + } catch { + return 0 + } +} + /** - * Watch settings source directories for changes. On a `settings.json` / - * `settings.local.json` change, call the reload callback which should re-run - * loadChain() and update the state. + * Poll `.opencode/hooks.json` (project + worktree) for mtime changes. On a + * change, call the reload callback which should re-run loadChain() and update + * the state. `onReload` mutates the cached state object in place (same contract + * as the prior fs.watch implementation). * * @param projectDir - Project root directory - * @param worktree - Optional git worktree root; its .claude/.opencode dirs are watched too + * @param worktree - Optional git worktree root; its .opencode/hooks.json is polled too * @param reload - Effect that re-runs loadChain() and returns new Settings * @param onReload - Callback invoked with new settings and changed file path - * @param opencodeGlobalConfig - Optional path to opencode global config dir + * @param _opencodeGlobalConfig - Retained for signature compatibility; the global + * hooks.json is loaded once at startup and intentionally NOT polled. */ export function watchSettings( projectDir: string, worktree: string | undefined, reload: () => Effect.Effect, onReload: (newSettings: Settings, changedFile: string) => void, - opencodeGlobalConfig?: string, + _opencodeGlobalConfig?: string, ): HotReloadHandle { - const watchers: FSWatcher[] = [] let debounceTimer: ReturnType | null = null let lastReload = 0 + let closed = false - // Watch parent dirs and filter by settings filename inside the callback, so - // a settings file created at runtime is detected even though it did not - // exist when watching started. - const watchedNames = new Set(["settings.json", "settings.local.json"]) - const dirs = settingsDirs(projectDir, worktree, opencodeGlobalConfig) - log.info("watching settings dirs", { count: dirs.length, dirs }) + const files = watchedFiles(projectDir, worktree) + // Seed mtime snapshots so a pre-existing file does not fire on the first poll. + const mtimes = new Map(files.map((f) => [f, mtimeOrZero(f)])) + log.info("polling hooks.json files", { files }) - for (const dir of dirs) { - try { - const watcher = watch(dir, { persistent: false }, (_eventType, filename) => { - if (!filename || !watchedNames.has(filename)) return + const fireReload = (changedFile: string) => { + log.info("hooks.json changed, reloading", { file: changedFile }) + // Fire-and-forget: reload errors are logged but never crash + Effect.runPromise(reload()).then( + (settings) => { + log.info("hooks hot-reloaded", { file: changedFile, hookCount: countHooks(settings) }) + onReload(settings, changedFile) + }, + (err) => log.warn("hooks reload failed", { file: changedFile, error: String(err) }), + ) + } - // Debounce: 500ms. Min 1s between reloads. - // On min-interval block, reschedule (not drop) so rapid successive - // saves are not permanently lost. - if (debounceTimer) clearTimeout(debounceTimer) - const file = path.join(dir, filename) - const tryReload = () => { - const now = Date.now() - if (now - lastReload < 1000) { - debounceTimer = setTimeout(tryReload, 1000 - (now - lastReload)) - return - } - lastReload = now - log.info("settings file changed, reloading", { file }) - // Fire-and-forget: reload errors are logged but never crash - Effect.runPromise(reload()).then( - (settings) => { - log.info("settings hot-reloaded", { - file, - hookCount: countHooks(settings), - }) - onReload(settings, file) - }, - (err) => log.warn("settings reload failed", { file, error: String(err) }), - ) - } - debounceTimer = setTimeout(tryReload, 500) - }) - watchers.push(watcher) - } catch { - // Dir removed between settingsDirs() and watch() โ€” harmless. - log.debug("skipping non-existent settings dir", { dir }) + // Debounce: 500ms. Min 1s between reloads. On min-interval block, reschedule + // (not drop) so rapid successive saves are not permanently lost. + const scheduleReload = (changedFile: string) => { + if (debounceTimer) clearTimeout(debounceTimer) + const tryReload = () => { + const now = Date.now() + if (now - lastReload < 1000) { + debounceTimer = setTimeout(tryReload, 1000 - (now - lastReload)) + return + } + lastReload = now + fireReload(changedFile) } + debounceTimer = setTimeout(tryReload, 500) + } + + const check = () => { + if (closed) return + // 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 + if (m !== prev) { + mtimes.set(f, m) + changedFile = f + } + } + if (changedFile) scheduleReload(changedFile) + } + + const interval = setInterval(check, POLL_INTERVAL_MS) + // Don't keep the process alive just for polling (mirrors fs.watch persistent:false). + if (typeof interval === "object" && "unref" in interval && typeof interval.unref === "function") { + interval.unref() } return { close() { + closed = true if (debounceTimer) clearTimeout(debounceTimer) - for (const w of watchers) w.close() - watchers.length = 0 - log.info("stopped watching settings files") + clearInterval(interval) + log.info("stopped polling hooks.json files") }, } } diff --git a/packages/opencode/src/hook/settings.ts b/packages/opencode/src/hook/settings.ts index a6ba5c374b..94fc0d5905 100644 --- a/packages/opencode/src/hook/settings.ts +++ b/packages/opencode/src/hook/settings.ts @@ -1,15 +1,17 @@ /** * Settings-based hook system โ€” Claude Code protocol-level 1:1 compatible. * - * Reads hooks from a six-layer settings chain (later layers concat on top of - * earlier ones, mirroring Claude Code's merge behavior): + * Reads hooks from a dedicated hooks.json chain (later layers concat-append on + * top of earlier ones, mirroring Claude Code's merge semantics โ€” hooks + * accumulate, they do not replace): * - * 1. ~/.claude/settings.json (CC global, shared) - * 2. /settings.json (OpenCode global, optional) - * 3. /.claude/settings.json - * 4. /.opencode/settings.json (OpenCode project, optional) - * 5. /.claude/settings.local.json (CC project local) - * 6. /.opencode/settings.local.json (OpenCode project local) + * 1. ~/.config/opencode/hooks.json (global, loaded once at startup) + * 2. /.opencode/hooks.json (project, hot-reloaded) + * 3. /.opencode/hooks.json (worktree, hot-reloaded, when โ‰  project) + * + * `.claude/` directories are NOT read for hooks (complete cut); a leftover + * `hooks` field in OpenCode-owned settings.json files triggers a one-time + * deprecation warning pointing at /import-claude-hooks (hooks there are ignored). * * Supports the Claude Code hook event surface at the protocol/schema layer. * @@ -96,6 +98,37 @@ export type HookEvent = | "CwdChanged" | "FileChanged" +// Runtime set of valid hook event names (for filtering non-event keys in hooks.json) +const VALID_HOOK_EVENTS = new Set([ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "PermissionRequest", + "PermissionDenied", + "Setup", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "SessionStart", + "SessionEnd", + "TeammateIdle", + "TaskCreated", + "TaskCompleted", + "Elicitation", + "ElicitationResult", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded", + "CwdChanged", + "FileChanged", +]) + export interface HookCommand { /** * Hook execution kind. All 5 types fully implemented: @@ -167,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 @@ -506,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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ /** @@ -537,17 +605,41 @@ function matches(matcher: string | undefined, target: string): boolean { } } -// โ”€โ”€ Settings loader (six-layer chain) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// โ”€โ”€ Settings loader (hooks.json chain) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -function readJSON(filepath: string): Settings | null { +// Exported for unit testing only; not part of the public surface. +export function readJSON(filepath: string): Settings | null { if (!existsSync(filepath)) return null try { - const data = JSON.parse(readFileSync(filepath, "utf8")) as Settings - // Stamp every HookCommand with the directory of the settings file that declared it. - // execShell uses this to populate CLAUDE_PLUGIN_ROOT / CLAUDE_PLUGIN_DATA. - if (data.hooks) { - const sourceDir = path.dirname(filepath) - for (const matchers of Object.values(data.hooks)) { + const parsed = JSON.parse(readFileSync(filepath, "utf8")) + // hooks.json uses top-level event keys; a legacy {"hooks": {...}} wrapper is + // tolerated (D1 graceful degradation). The wrapper wins when present. + const obj = + parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : undefined + const rawHooks = obj && obj.hooks && typeof obj.hooks === "object" && !Array.isArray(obj.hooks) + ? obj.hooks as Record + : obj + + // Filter to only valid HookEvent keys with array values (defends against + // non-event keys like "$schema" being treated as matchers) + const hooks: Settings["hooks"] = {} + if (rawHooks && typeof rawHooks === "object") { + for (const [key, value] of Object.entries(rawHooks)) { + if (VALID_HOOK_EVENTS.has(key) && Array.isArray(value)) { + hooks[key as HookEvent] = value + } + } + } + + // Stamp every HookCommand with the directory of the hooks.json file that + // declared it. execShell uses this to populate CLAUDE_PLUGIN_ROOT / + // CLAUDE_PLUGIN_DATA โ€” now resolves to .opencode/ or ~/.config/opencode/ + // rather than .claude/. + const sourceDir = path.dirname(filepath) + if (hooks) { + for (const matchers of Object.values(hooks)) { if (!matchers) continue for (const m of matchers) { for (const h of m.hooks ?? []) h.__sourceDir = sourceDir @@ -556,11 +648,11 @@ function readJSON(filepath: string): Settings | null { } log.info("loaded hook settings", { path: filepath, - events: Object.keys(data.hooks ?? {}), + events: Object.keys(hooks), }) - return data + return { hooks } } catch (err) { - log.error("failed to parse settings.json", { path: filepath, error: String(err) }) + log.error("failed to parse hooks.json", { path: filepath, error: String(err) }) return null } } @@ -569,7 +661,8 @@ function readJSON(filepath: string): Settings | null { * Concat-merge hook matchers across layers. Later layers append after earlier * ones (matches CC's merge semantics โ€” does NOT replace by matcher key). */ -function mergeSettings(layers: Settings[]): Settings { +// Exported for unit testing only; not part of the public surface. +export function mergeSettings(layers: Settings[]): Settings { const out: Settings = { hooks: {} } for (const layer of layers) { if (!layer.hooks) continue @@ -582,44 +675,173 @@ function mergeSettings(layers: Settings[]): Settings { return out } -function loadChain(directory: string, worktree: string): Settings { - const home = os.homedir() - // Best-effort OpenCode global path; falls back to ~/.config/opencode - const opencodeGlobal = (() => { - try { - return Global.Path.config - } catch { - return path.join(home, ".config", "opencode") - } - })() +/** + * 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 opencodeGlobal = resolveGlobalConfig(globalConfig) + + 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) + + // Deprecation scan: warn once per OpenCode-owned settings.json that still + // carries a `hooks` field (D4). Hooks there are NOT loaded โ€” the warning is + // the only signal. `.claude/` files are never scanned (silent ignore per spec). + for (const fp of deprecatedSettingsPaths(opencodeGlobal, directory, worktree)) { + warnDeprecatedHooksField(fp) + } + + return mergeSettings(layers) +} - const candidates = [ - path.join(home, ".claude", "settings.json"), +/** + * Tracks OpenCode-owned `settings.json` files whose deprecated `hooks` field has + * already been flagged. loadChain's deprecation scan warns once per file so a + * hot-reload (which re-runs loadChain) does not re-warn the same file. The fork's + * logger is a noop shim, so Set membership is the only observable signal โ€” used + * by __hasWarnedDeprecated for tests. `.claude/` files are never scanned (silent + * ignore per spec); only OpenCode-owned settings.json paths are. + */ +const warnedDeprecatedHooks = new Set() + +/** + * OpenCode-owned settings.json paths that previously carried a `hooks` field. + * `.claude/` is deliberately excluded (silent ignore). Used by the deprecation + * scan so users who had hooks in settings.json learn they moved to hooks.json. + */ +function deprecatedSettingsPaths(opencodeGlobal: string, directory: string, worktree: string): string[] { + const paths = [ path.join(opencodeGlobal, "settings.json"), - path.join(directory, ".claude", "settings.json"), path.join(directory, ".opencode", "settings.json"), - path.join(directory, ".claude", "settings.local.json"), path.join(directory, ".opencode", "settings.local.json"), ] - - // If worktree differs from directory (e.g. git worktree), also check it if (worktree && worktree !== directory) { - candidates.push( - path.join(worktree, ".claude", "settings.json"), + paths.push( path.join(worktree, ".opencode", "settings.json"), - path.join(worktree, ".claude", "settings.local.json"), path.join(worktree, ".opencode", "settings.local.json"), ) } + return paths +} - const layers = candidates - .map((fp) => { - const data = readJSON(fp) - if (data) warnUnsupportedFields(data.hooks, path.dirname(fp)) - return data - }) - .filter((s): s is Settings => s !== null) - return mergeSettings(layers) +/** + * True when the JSON object at filepath has a non-empty `hooks` field. Parse or + * missing-file errors return false silently (unreadable deprecated files are not + * worth warning about). The value is checked for truthiness so an explicit + * `"hooks": {}` / `"hooks": null` does not trigger a noisy false alarm. + */ +function hasHooksField(filepath: string): boolean { + try { + const parsed = JSON.parse(readFileSync(filepath, "utf8")) + return ( + parsed !== null && + typeof parsed === "object" && + !Array.isArray(parsed) && + "hooks" in parsed && + Boolean((parsed as { hooks?: unknown }).hooks) + ) + } catch { + return false + } +} + +/** + * One-time-per-file deprecation warning for a `hooks` field left in an old + * settings.json. Tracked via warnedDeprecatedHooks so hot-reload (which re-runs + * loadChain) does not re-warn. The logger is a noop shim in this fork, so Set + * membership is the observable signal consumed by __hasWarnedDeprecated. + */ +function warnDeprecatedHooksField(filepath: string): void { + if (warnedDeprecatedHooks.has(filepath) || !existsSync(filepath)) return + if (!hasHooksField(filepath)) return + log.warn( + `hooks field found in ${filepath} โ€” hooks are now loaded from hooks.json. Run /import-claude-hooks to migrate.`, + ) + warnedDeprecatedHooks.add(filepath) +} + +/** + * @internal Test-only: whether a settings.json path was flagged as carrying a + * deprecated `hooks` field during loadChain's deprecation scan. + */ +export function __hasWarnedDeprecated(filepath: string): boolean { + return warnedDeprecatedHooks.has(filepath) +} + +/** + * @internal Test-only: reset the deprecation tracking so each test starts from a + * clean warning state. + */ +export function __resetDeprecatedWarnings(): void { + warnedDeprecatedHooks.clear() } /** @@ -981,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 { @@ -988,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") {} @@ -1339,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 @@ -1350,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, ) @@ -1624,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 7aa7cfe6c3..f8ea0fbcad 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1456,13 +1456,13 @@ export const layer = Layer.effect( yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - const [skills, env, instructions, mcpInstructions, hooksDocs, 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.hooks(), sys.goal(sessionID), + sys.hooks(), MessageV2.toModelMessagesEffect(msgs, model), ]) const system = [ @@ -1470,8 +1470,8 @@ export const layer = Layer.effect( ...instructions, ...(mcpInstructions ? [mcpInstructions] : []), ...(skills ? [skills] : []), - ...hooksDocs, ...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/prompt/hooks.txt b/packages/opencode/src/session/prompt/hooks.txt deleted file mode 100644 index d312338e4c..0000000000 --- a/packages/opencode/src/session/prompt/hooks.txt +++ /dev/null @@ -1,37 +0,0 @@ -# Claude Code Hooks API - -OpenCode is **fully compatible with the Claude Code hooks protocol**. The hooks system is built-in and active. When installing plugins or configuring automations, **always use the standard Claude Code hooks mechanism** โ€” do NOT skip hooks or assume they are unavailable. - -## Quick Reference - -**Settings paths** (6-layer chain, merged in order): -1. `~/.claude/settings.json` (global) -2. `~/.config/opencode/settings.json` (OpenCode global) -3. `/.claude/settings.json` -4. `/.opencode/settings.json` -5. `/.claude/settings.local.json` -6. `/.opencode/settings.local.json` - -**Hook types**: `command` | `mcp` | `http` | `prompt` | `agent` - -**22 actively triggered events**: PreToolUse, PostToolUse, PostToolUseFailure, FileChanged, UserPromptSubmit, Stop, StopFailure, InstructionsLoaded, SessionStart, SessionEnd, PermissionRequest, PermissionDenied, SubagentStart, SubagentStop, TaskCreated, TaskCompleted, TeammateIdle, PreCompact, PostCompact, WorktreeCreate, WorktreeRemove, ConfigChange - -**Config format**: -```json -{ - "hooks": { - "PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "your-script.sh" }] }] - } -} -``` - -**Exit codes**: 0=allow (parse stdout JSON), 2=block (stderr=reason), other=log+continue - -**Env vars**: `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, `CLAUDE_PLUGIN_DATA` - -## Detailed Reference - -For the complete protocol specification (stdin/stdout envelope, all event payloads, template variables, hookSpecificOutput fields), read: -``` -~/.config/opencode/docs/hooks-reference.md -``` diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 0fa5a5a190..0e0cdd3b3a 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -12,7 +12,6 @@ import PROMPT_KIMI from "./prompt/kimi.txt" import PROMPT_CODEX from "./prompt/codex.txt" import PROMPT_TRINITY from "./prompt/trinity.txt" -import PROMPT_HOOKS from "./prompt/hooks.txt" import PROMPT_GOAL from "./prompt/goal.txt" import type { Provider } from "@/provider/provider" import type { Agent } from "@/agent/agent" @@ -26,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,8 +48,8 @@ export interface Interface { readonly environment: (model: Provider.Model) => Effect.Effect readonly skills: (agent: Agent.Info) => Effect.Effect readonly mcp: (agent: Agent.Info, permission?: PermissionV1.Ruleset) => Effect.Effect - readonly hooks: () => Effect.Effect readonly goal: (sessionID: SessionID) => Effect.Effect + readonly hooks: () => Effect.Effect } export class Service extends Context.Service()("@opencode/SystemPrompt") {} @@ -139,10 +139,6 @@ export const layer = Layer.effect( ].join("\n") }), - hooks: Effect.fn("SystemPrompt.hooks")(function* () { - return [PROMPT_HOOKS] - }), - goal: Effect.fn("SystemPrompt.goal")(function* (sessionID: SessionID) { // No Goal service wired into this entry point โ†’ degrade to a terse note. if (!goalSvc) return ["No autonomous goal is active for this session."] @@ -156,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 b8bd6bef6e..64aba839eb 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -30,10 +30,18 @@ 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 +// (events, hooks.json format, handler types) โ€” without this skill they either +// guess wrong or never discover hooks exist. Description alone is enough to +// 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 = SkillPlugin.ConfigureHooksDescription +const CONFIGURE_HOOKS_SKILL_BODY = SkillPlugin.ConfigureHooksContent + export const Info = Schema.Struct({ name: Schema.String, description: Schema.optional(Schema.String), @@ -281,6 +289,12 @@ export const layer = Layer.effect( location: "", content: CUSTOMIZE_OPENCODE_SKILL_BODY, } + s.skills[CONFIGURE_HOOKS_SKILL_NAME] = { + name: CONFIGURE_HOOKS_SKILL_NAME, + description: CONFIGURE_HOOKS_SKILL_DESCRIPTION, + location: "", + content: CONFIGURE_HOOKS_SKILL_BODY, + } yield* loadSkills(s, yield* InstanceState.get(discovered), events) return s }), diff --git a/packages/opencode/src/tool/goal.ts b/packages/opencode/src/tool/goal.ts index 4c0964a635..f72ef9996f 100644 --- a/packages/opencode/src/tool/goal.ts +++ b/packages/opencode/src/tool/goal.ts @@ -23,16 +23,21 @@ type Metadata = { } | null } -// Goal.Service is resolved lazily (serviceOption) rather than declared as -// a hard dependency of the tool layer. This keeps ToolRegistry's requirement -// set small and lets the tool degrade gracefully if Goal isn't provided by -// the entry point โ€” matching how src/session/prompt.ts and -// src/session/session.ts access Goal.Service. +// Goal.Service MUST be resolved inside `execute` (request phase), NOT in this +// build-phase `init` gen. `init` runs once during ToolRegistry construction, +// which lives in one Layer.mergeAll group of AppLayer while Goal.defaultLayer +// lives in a sibling group; mergeAll siblings cannot see each other's outputs, +// so a build-phase serviceOption(Goal.Service) is guaranteed None and would be +// captured in this closure, permanently no-op-ing the tool (verified by the +// runtime "autonomous goal service is not available" symptom). At execute time +// the session request context carries the full AppLayer, so Goal.Service is +// reachable. This corrects the misleading reference in goal-loop-correctness +// task 6.1, which cited the old build-phase probe as the pattern to follow. +// serviceOption contributes R = never, so Tool.define<โ€ฆ, never> is unchanged +// and headless runtimes that omit Goal still degrade gracefully below. export const GoalTool = Tool.define( "goal", Effect.gen(function* () { - const goal = Option.getOrUndefined(yield* Effect.serviceOption(Goal.Service)) - return { description: DESCRIPTION, parameters: Parameters, @@ -41,6 +46,7 @@ export const GoalTool = Tool.define( // Goal state belongs to the session itself; it is not an external // resource boundary (no filesystem, no network, no cross-session // write), so it does not need a permission gate. + const goal = Option.getOrUndefined(yield* Effect.serviceOption(Goal.Service)) if (!goal) { // Goal service not wired into this entry point (some headless diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index b0b8836291..8be3f39589 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -90,6 +90,10 @@ export const TaskTool = Tool.define( const scope = yield* Scope.Scope const flags = yield* RuntimeFlags.Service const database = yield* Database.Service + // Build-phase serviceOption is SAFE here, unlike tool/goal.ts: SettingsHook + // arrives via Layer.provideMerge (app-runtime.ts), whose output is visible to + // the ToolRegistry mergeAll group during construction, so this resolves Some. + // Goal.Service, by contrast, is a mergeAll sibling and invisible at build. const settingsHook = Option.getOrUndefined(yield* Effect.serviceOption(SettingsHook.Service)) const run = Effect.fn("TaskTool.execute")(function* ( 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/load-chain.test.ts b/packages/opencode/test/hook/load-chain.test.ts new file mode 100644 index 0000000000..67c28e8898 --- /dev/null +++ b/packages/opencode/test/hook/load-chain.test.ts @@ -0,0 +1,399 @@ +import { describe, expect, test, beforeEach } from "bun:test" +import { Effect } from "effect" +import * as fs from "fs/promises" +import os from "os" +import path from "path" +import { + loadChain, + mergeSettings, + readJSON, + __hasWarnedDeprecated, + __resetDeprecatedWarnings, + type Settings, +} from "@/hook/settings" +import { watchSettings } from "@/hook/extensions" + +// ยง4 unit tests for loadChain + hot-reload โ€” previously zero coverage. +// +// Each test builds isolated temp dirs for the global / project / worktree scopes +// and drives loadChain (or watchSettings) directly. loadChain's optional third +// arg `globalConfig` points the global layer at an isolated temp dir instead of +// the real ~/.config/opencode, so no real-machine state is touched. + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +async function mktmp(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), `hook-${prefix}-`)) + return dir +} + +// Top-level-events hooks.json content (D1 canonical format): event names are +// top-level keys. `marker` is the shell command so merge-order tests can tell +// layers apart by inspecting the concatenated matcher list. +const hooksJsonTopLevel = (marker: string) => ({ + SessionStart: [{ matcher: "*", hooks: [{ type: "command", command: marker }] }], +}) + +// Legacy wrapper format {"hooks": {...}} โ€” tolerated via graceful degradation. +const hooksJsonWrapped = (marker: string) => ({ + hooks: { SessionStart: [{ matcher: "*", hooks: [{ type: "command", command: marker }] }] }, +}) + +async function writeHooksJson(dir: string, json: unknown): Promise { + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + const file = path.join(opencodeDir, "hooks.json") + await fs.writeFile(file, JSON.stringify(json)) + return file +} + +async function writeGlobalHooksJson(globalDir: string, json: unknown): Promise { + await fs.mkdir(globalDir, { recursive: true }) + const file = path.join(globalDir, "hooks.json") + await fs.writeFile(file, JSON.stringify(json)) + return file +} + +async function writeSettingsJson(dir: string, json: unknown): Promise { + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + const file = path.join(opencodeDir, "settings.json") + await fs.writeFile(file, JSON.stringify(json)) + return file +} + +async function writeClaudeSettingsJson(dir: string, json: unknown): Promise { + const claudeDir = path.join(dir, ".claude") + await fs.mkdir(claudeDir, { recursive: true }) + const file = path.join(claudeDir, "settings.json") + await fs.writeFile(file, JSON.stringify(json)) + return file +} + +beforeEach(() => { + __resetDeprecatedWarnings() +}) + +describe("ยง4.1 loadChain reads hooks.json from correct paths (global + project + worktree)", () => { + test("all three scopes contribute their SessionStart matcher", async () => { + const globalDir = await mktmp("global") + const projectDir = await mktmp("project") + const worktreeDir = await mktmp("worktree") + try { + await writeGlobalHooksJson(globalDir, hooksJsonTopLevel("global")) + await writeHooksJson(projectDir, hooksJsonTopLevel("project")) + await writeHooksJson(worktreeDir, hooksJsonTopLevel("worktree")) + + const merged = loadChain(projectDir, worktreeDir, globalDir) + const matchers = merged.hooks?.SessionStart ?? [] + expect(matchers.length).toBe(3) + expect(matchers.map((m) => m.hooks[0].command)).toEqual(["global", "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("ยง4.2 merge order is global โ†’ project โ†’ worktree concat-append (not override)", () => { + test("mergeSettings concatenates layers in order", () => { + const g: Settings = { hooks: { SessionStart: [{ matcher: "*", hooks: [{ type: "command", command: "g" }] }] } } + const p: Settings = { hooks: { SessionStart: [{ matcher: "*", hooks: [{ type: "command", command: "p" }] }] } } + const w: Settings = { hooks: { SessionStart: [{ matcher: "*", hooks: [{ type: "command", command: "w" }] }] } } + const out = mergeSettings([g, p, w]) + // Three distinct matchers appended โ€” NOT collapsed to a single matcher. + expect(out.hooks?.SessionStart?.length).toBe(3) + expect(out.hooks?.SessionStart?.[0].hooks[0].command).toBe("g") + expect(out.hooks?.SessionStart?.[1].hooks[0].command).toBe("p") + expect(out.hooks?.SessionStart?.[2].hooks[0].command).toBe("w") + }) + + test("loadChain preserves global โ†’ project โ†’ worktree order on disk", async () => { + const globalDir = await mktmp("global") + const projectDir = await mktmp("project") + const worktreeDir = await mktmp("worktree") + try { + await writeGlobalHooksJson(globalDir, hooksJsonTopLevel("g")) + await writeHooksJson(projectDir, hooksJsonTopLevel("p")) + await writeHooksJson(worktreeDir, hooksJsonTopLevel("w")) + + const merged = loadChain(projectDir, worktreeDir, globalDir) + const cmds = (merged.hooks?.SessionStart ?? []).map((m) => m.hooks[0].command) + expect(cmds).toEqual(["g", "p", "w"]) + } 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("ยง4.3 top-level events format (no wrapper) parses correctly", () => { + test("readJSON parses {PreToolUse: [...]} and stamps __sourceDir to the hooks.json dir", async () => { + const dir = await mktmp("fmt") + try { + const file = await writeHooksJson(dir, { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "./check.sh" }] }], + }) + const settings = readJSON(file) + expect(settings?.hooks?.PreToolUse?.length).toBe(1) + expect(settings?.hooks?.PreToolUse?.[0].matcher).toBe("Bash") + // __sourceDir must point at the directory containing hooks.json (the .opencode dir). + expect(settings?.hooks?.PreToolUse?.[0].hooks[0].__sourceDir).toBe(path.dirname(file)) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe("ยง4.4 wrapper-detection fallback tolerates legacy {hooks: {...}}", () => { + test("readJSON extracts the inner object when a wrapper is present", async () => { + const dir = await mktmp("wrap") + try { + const file = await writeHooksJson(dir, hooksJsonWrapped("legacy")) + const settings = readJSON(file) + expect(settings?.hooks?.SessionStart?.length).toBe(1) + expect(settings?.hooks?.SessionStart?.[0].hooks[0].command).toBe("legacy") + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + test("loadChain loads a wrapped hooks.json into the merge", async () => { + const globalDir = await mktmp("g") + const projectDir = await mktmp("p") + try { + await writeHooksJson(projectDir, hooksJsonWrapped("wrapped-project")) + const merged = loadChain(projectDir, "", globalDir) + expect(merged.hooks?.SessionStart?.[0].hooks[0].command).toBe("wrapped-project") + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true })]) + } + }) +}) + +describe("P1: readJSON filters non-event keys (e.g. $schema) via VALID_HOOK_EVENTS + Array.isArray", () => { + test("$schema and other non-event keys are silently dropped, only valid events with array values are kept", async () => { + const dir = await mktmp("schema") + try { + await writeHooksJson(dir, { + $schema: "https://example.com/hooks-schema.json", + version: 1, + SessionStart: [{ matcher: "*", hooks: [{ type: "command", command: "echo hi" }] }], + Stop: [{ matcher: "*", hooks: [{ type: "command", command: "echo bye" }] }], + badEvent: "not-an-array", + }) + const merged = loadChain(dir, "", dir) + // Valid events are kept + expect(merged.hooks?.SessionStart?.length).toBe(1) + expect(merged.hooks?.Stop?.length).toBe(1) + // $schema, version, badEvent are all filtered out + expect((merged.hooks as Record)?.$schema).toBeUndefined() + expect((merged.hooks as Record)?.version).toBeUndefined() + expect((merged.hooks as Record)?.badEvent).toBeUndefined() + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + test("readJSON returns empty hooks object when file contains only non-event keys", async () => { + const dir = await mktmp("onlymeta") + try { + const file = await writeHooksJson(dir, { $schema: "x", version: 1, description: "empty" }) + const settings = readJSON(file) + expect(settings?.hooks).toEqual({}) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe("ยง4.5 deprecation warning fires when settings.json has a hooks field", () => { + test("settings.json hooks field is flagged and NOT loaded into the merge", async () => { + const globalDir = await mktmp("g") + const projectDir = await mktmp("p") + try { + const settingsFile = await writeSettingsJson(projectDir, { + hooks: { SessionStart: [{ matcher: "*", hooks: [{ type: "command", command: "stale" }] }] }, + }) + const merged = loadChain(projectDir, "", globalDir) + // Hooks from settings.json are silently ignored (not in the merge)... + expect(merged.hooks?.SessionStart ?? []).toEqual([]) + // ...but the deprecation scan flagged the file. + expect(__hasWarnedDeprecated(settingsFile)).toBe(true) + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true })]) + } + }) + + test("deprecation is warned once per file across repeated loadChain calls", async () => { + const globalDir = await mktmp("g") + const projectDir = await mktmp("p") + try { + const settingsFile = await writeSettingsJson(projectDir, { + hooks: { SessionStart: [{ matcher: "*", hooks: [{ type: "command", command: "stale" }] }] }, + }) + __resetDeprecatedWarnings() + loadChain(projectDir, "", globalDir) + expect(__hasWarnedDeprecated(settingsFile)).toBe(true) + // Second call must not re-flag (Set dedup simulates hot-reload behavior). + loadChain(projectDir, "", globalDir) + expect(__hasWarnedDeprecated(settingsFile)).toBe(true) + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true })]) + } + }) +}) + +describe("ยง4.6 .claude/ paths are NOT read", () => { + test("hooks in .claude/settings.json do not appear in the merge and are not scanned", async () => { + const globalDir = await mktmp("g") + const projectDir = await mktmp("p") + try { + const claudeFile = await writeClaudeSettingsJson(projectDir, { + hooks: { SessionStart: [{ matcher: "*", hooks: [{ type: "command", command: "claude-only" }] }] }, + }) + const merged = loadChain(projectDir, "", globalDir) + // .claude/ hooks never reach the merge... + expect(merged.hooks?.SessionStart ?? []).toEqual([]) + // ...and .claude/ is never scanned for the deprecation warning (silent ignore). + expect(__hasWarnedDeprecated(claudeFile)).toBe(false) + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true })]) + } + }) +}) + +describe("ยง4.7 polling reload: modify hooks.json โ†’ reload fires with new settings", () => { + test("project hooks.json change triggers onReload with the updated chain", async () => { + const globalDir = await mktmp("g") + const projectDir = await mktmp("p") + try { + await writeGlobalHooksJson(globalDir, hooksJsonTopLevel("g")) + await writeHooksJson(projectDir, hooksJsonTopLevel("v1")) + + let reloaded: Settings | undefined + let changed: string | undefined + const handle = watchSettings( + projectDir, + undefined, + () => Effect.sync(() => loadChain(projectDir, "", globalDir)), + (newSettings, changedFile) => { + reloaded = newSettings + changed = changedFile + }, + globalDir, + ) + + // Ensure a distinct mtime, then rewrite the project hooks.json to v2. + await sleep(50) + await writeHooksJson(projectDir, hooksJsonTopLevel("v2")) + + // Polling runs every 2s + 500ms debounce; 4s is past one full cycle. + await sleep(4000) + + const cmds = (reloaded?.hooks?.SessionStart ?? []).map((m) => m.hooks[0].command) + expect(cmds).toContain("v2") + expect(changed).toBe(path.join(projectDir, ".opencode", "hooks.json")) + + handle.close() + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true })]) + } + }) +}) + +describe("ยง4.8 global hooks.json is NOT reloaded on change (startup-only)", () => { + test("modifying global hooks.json does not trigger onReload", async () => { + const globalDir = await mktmp("g") + const projectDir = await mktmp("p") + try { + await writeGlobalHooksJson(globalDir, hooksJsonTopLevel("global-v1")) + await writeHooksJson(projectDir, hooksJsonTopLevel("project-stable")) + + let reloadCount = 0 + const handle = watchSettings( + projectDir, + undefined, + () => Effect.sync(() => loadChain(projectDir, "", globalDir)), + () => { + reloadCount += 1 + }, + globalDir, + ) + + // Modify ONLY the global hooks.json โ€” project file stays untouched. + await sleep(50) + await writeGlobalHooksJson(globalDir, hooksJsonTopLevel("global-v2")) + + // Past one full poll cycle: if global were watched, it would have fired. + await sleep(4000) + expect(reloadCount).toBe(0) + + handle.close() + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true })]) + } + }) + + test("after close(), even project hooks.json changes do not reload", async () => { + const globalDir = await mktmp("g") + const projectDir = await mktmp("p") + try { + await writeHooksJson(projectDir, hooksJsonTopLevel("v1")) + + let reloadCount = 0 + const handle = watchSettings( + projectDir, + undefined, + () => Effect.sync(() => loadChain(projectDir, "", globalDir)), + () => { + reloadCount += 1 + }, + globalDir, + ) + + handle.close() + await sleep(50) + await writeHooksJson(projectDir, hooksJsonTopLevel("after-close")) + await sleep(4000) + expect(reloadCount).toBe(0) + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true })]) + } + }) +}) + +describe("P2: deleting hooks.json triggers reload (mtime from >0 to 0)", () => { + test("project hooks.json deletion triggers onReload with empty settings", async () => { + const globalDir = await mktmp("g") + const projectDir = await mktmp("p") + try { + // Start with a hooks.json that has real hooks + await writeHooksJson(projectDir, hooksJsonTopLevel("before-delete")) + const hooksFile = path.join(projectDir, ".opencode", "hooks.json") + + let reloaded: Settings | undefined + const handle = watchSettings( + projectDir, + undefined, + () => Effect.sync(() => loadChain(projectDir, "", globalDir)), + (s) => { reloaded = s }, + ) + + // Verify initial load has the hook + const initial = loadChain(projectDir, "", globalDir) + expect(initial.hooks?.SessionStart?.[0].hooks[0].command).toBe("before-delete") + + // Delete the file + await fs.unlink(hooksFile) + // Wait for polling: 2s interval + 500ms debounce + margin + await sleep(4000) + + // Reload should have fired, and now there are no hooks + expect(reloaded).toBeDefined() + expect(reloaded?.hooks?.SessionStart ?? []).toHaveLength(0) + + handle.close() + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true })]) + } + }, 12000) +}) diff --git a/packages/opencode/test/hook/settings-dedup.test.ts b/packages/opencode/test/hook/settings-dedup.test.ts index e2423ee227..6638d99f9c 100644 --- a/packages/opencode/test/hook/settings-dedup.test.ts +++ b/packages/opencode/test/hook/settings-dedup.test.ts @@ -14,7 +14,7 @@ import { testEffect } from "../lib/effect" // assert the SessionEnd dynamic-hook-store cleanup. The body of each // it.instance test runs inside a fresh temp instance dir (via withTmpdirInstance), // so SettingsHook's InstanceState-built state (loadChain + the seen Map) is -// fresh per test and loadChain reads the .opencode/settings.json we write. +// fresh per test and loadChain reads the .opencode/hooks.json we write. const testLayer = SettingsHook.layer.pipe( Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Database.defaultLayer), @@ -30,18 +30,18 @@ const it = testEffect(testLayer) // /bin/sh -c handles it on the test host. const CONTEXT = "ctx-F2-dedup-marker" const HOOK_JSON = JSON.stringify({ hookSpecificOutput: { additionalContext: CONTEXT } }) -const settingsJson = { - hooks: { - SessionStart: [{ hooks: [{ type: "command", command: `printf '%s' '${HOOK_JSON}'` }] }], - }, +// hooks.json uses top-level event keys (D1 canonical format); loadChain reads +// .opencode/hooks.json (no longer .opencode/settings.json). +const hooksJson = { + SessionStart: [{ hooks: [{ type: "command", command: `printf '%s' '${HOOK_JSON}'` }] }], } -// Writes /.opencode/settings.json (loadChain reads this path), so the +// Writes /.opencode/hooks.json (loadChain reads this path), so the // SessionStart matcher participates in the trigger pipeline. const writeSettings = (dir: string) => Effect.promise(() => fs.mkdir(path.join(dir, ".opencode"), { recursive: true }).then(() => - fs.writeFile(path.join(dir, ".opencode", "settings.json"), JSON.stringify(settingsJson)), + fs.writeFile(path.join(dir, ".opencode", "hooks.json"), JSON.stringify(hooksJson)), ), ) diff --git a/packages/opencode/test/hook/settings-hot-reload.test.ts b/packages/opencode/test/hook/settings-hot-reload.test.ts index 953d28a72b..136f7ffbb2 100644 --- a/packages/opencode/test/hook/settings-hot-reload.test.ts +++ b/packages/opencode/test/hook/settings-hot-reload.test.ts @@ -33,13 +33,12 @@ const hookJson = (ctx: string) => JSON.stringify({ hookSpecificOutput: { additionalContext: ctx } }) const settingsFor = (ctx: string) => ({ - hooks: { - SessionStart: [{ hooks: [{ type: "command", command: `printf '%s' '${hookJson(ctx)}'` }] }], - }, + // hooks.json uses top-level event keys (D1 canonical format). + SessionStart: [{ hooks: [{ type: "command", command: `printf '%s' '${hookJson(ctx)}'` }] }], }) const opencodeDir = (dir: string) => path.join(dir, ".opencode") -const settingsPath = (dir: string) => path.join(opencodeDir(dir), "settings.json") +const settingsPath = (dir: string) => path.join(opencodeDir(dir), "hooks.json") const writeSettings = (dir: string, ctx: string) => Effect.promise(async () => { @@ -50,12 +49,12 @@ const writeSettings = (dir: string, ctx: string) => const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) describe("SettingsHook hot-reload โ€” watchSettings wiring (F3)", () => { - // F3.1 integration: changing settings.json at runtime is picked up by the - // next trigger after the watcher debounce. The first trigger runs + // F3.1 integration: changing hooks.json at runtime is picked up by the + // next trigger after the polling debounce. The first trigger runs // InstanceState.get โ†’ loadChain (reads v1) AND wires watchSettings; the - // on-disk edit fires the .opencode dir watcher, reload() re-runs loadChain - // (reads v2), and onReload mutates the cached state object's .settings in - // place โ€” visible to trigger without invalidating the cache. + // on-disk edit is detected by the hooks.json mtime poll, reload() re-runs + // loadChain (reads v2), and onReload mutates the cached state object's + // .settings in place โ€” visible to trigger without invalidating the cache. it.instance( "runtime settings.json change is surfaced by the next trigger after debounce", () => @@ -75,9 +74,8 @@ describe("SettingsHook hot-reload โ€” watchSettings wiring (F3)", () => { // Poll until the hot-reload has mutated the cached settings to v2. // Each poll uses a fresh session so per-session dedup never masks the - // new marker. The reload is driven by the watcher's 500ms setTimeout - // debounce + fs.watch delivery, so we wait on the observable effect - // rather than a fixed sleep. + // new marker. The reload is driven by the 2s mtime poll + 500ms debounce, + // so we wait on the observable effect rather than a fixed sleep. let n = 0 yield* pollWithTimeout( Effect.gen(function* () { @@ -89,7 +87,7 @@ describe("SettingsHook hot-reload โ€” watchSettings wiring (F3)", () => { return r.additionalContexts.includes(CONTEXT_V2) ? true : undefined }), "settings hot-reload did not surface v2 within timeout", - "6 seconds", + "8 seconds", ) // Final confirmation with a clean session. @@ -102,16 +100,16 @@ describe("SettingsHook hot-reload โ€” watchSettings wiring (F3)", () => { { init: (dir) => writeSettings(dir, CONTEXT_V1) }, ) - // F3.2 + cleanup: watchSettings watches parent dirs (so it fires on a - // settings.json change) and handle.close() stops all reloads. Direct unit - // test of the watcher mechanism, independent of the SettingsHook layer. - // Fixed sleeps are justified here โ€” this test exercises debounce/throttle - // timing and proves absence of reload after close(). - test("watchSettings reloads on settings.json change and stops after close", async () => { + // F3.2 + cleanup: watchSettings polls .opencode/hooks.json (project + worktree + // only) and handle.close() stops all reloads. Direct unit test of the watcher + // mechanism, independent of the SettingsHook layer. Fixed sleeps are justified + // here โ€” this test exercises the polling/debounce/throttle timing and proves + // absence of reload after close(). + test("watchSettings reloads on hooks.json change and stops after close", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "hot-reload-")) const file = settingsPath(dir) await fs.mkdir(opencodeDir(dir), { recursive: true }) - await fs.writeFile(file, JSON.stringify({ hooks: {} })) + await fs.writeFile(file, JSON.stringify({})) let marker: string | undefined const handle = watchSettings( @@ -126,19 +124,53 @@ describe("SettingsHook hot-reload โ€” watchSettings wiring (F3)", () => { }, ) - // Trigger a change. fs.watch fires "change" for settings.json on overwrite. - await fs.writeFile(file, JSON.stringify({ hooks: { Stop: [] } })) - // Wait past the 500ms debounce for the reload to land. - await sleep(1000) + // Trigger a change. The poll checks mtime every 2s; ensure a distinct mtime + // from the construction snapshot, then wait past the 2s poll + 500ms debounce. + await sleep(50) + await fs.writeFile(file, JSON.stringify({ Stop: [] })) + await sleep(3000) expect(marker).toBe("reloaded") // After close(), further changes must NOT reload. handle.close() marker = undefined - await fs.writeFile(file, JSON.stringify({ hooks: { Notification: [] } })) - await sleep(1000) + await fs.writeFile(file, JSON.stringify({ Notification: [] })) + await sleep(3000) expect(marker).toBeUndefined() 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" }] }], + }), + ) + }), + }, + ) }) diff --git a/packages/opencode/test/tool/goal-tool.test.ts b/packages/opencode/test/tool/goal-tool.test.ts new file mode 100644 index 0000000000..3ba125b036 --- /dev/null +++ b/packages/opencode/test/tool/goal-tool.test.ts @@ -0,0 +1,138 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Agent } from "../../src/agent/agent" +import { Goal } from "../../src/goal/goal" +import { GoalState } from "../../src/goal/state" +import { GoalTool } from "../../src/tool/goal" +import { MessageID, SessionID } from "../../src/session/schema" +import { Truncate } from "@/tool/truncate" +import type { Tool } from "@/tool/tool" +import { testEffect } from "../lib/effect" + +// Goal.Service is INTENTIONALLY absent from this build context โ€” it mirrors the +// production ToolRegistry build phase (AppLayer group2), where Goal.Service (a +// group1 Layer.mergeAll sibling) is not visible at construction. Goal is +// provided only at execute time below, matching the production request phase. +// +// Before the tool-init-service-resolution fix, the goal tool captured a +// build-phase `None` from Effect.serviceOption(Goal.Service) into a closure, so +// the reachability assertions below would FAIL regardless of the execute-time +// provide (the tool always returned "service unavailable"). These tests lock +// the fixed contract: the probe resolves in execute, where Goal.Service lives. +const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer)) + +function ctx(): Tool.Context { + return { + sessionID: SessionID.make("ses_goal_tool"), + messageID: MessageID.make("msg_goal_tool"), + callID: "call_goal_tool", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + } +} + +const activeGoal = { + goal: "read the docs", + status: "active", + turns_used: 2, + max_turns: 20, + created_at: Date.now(), + last_turn_at: Date.now(), + consecutive_parse_failures: 0, + subgoals: [], +} as GoalState.Info + +const doneGoal = { ...activeGoal, status: "done", turns_used: 3 } as GoalState.Info + +describe("tool.goal โ€” service resolution phase", () => { + it.instance("status reaches Goal.Service when provided at execute time", () => + Effect.gen(function* () { + const info = yield* GoalTool + const tool = yield* info.init() + const goalLayer = Layer.mock(Goal.Service, { + load: () => Effect.succeed(undefined), + }) + + const result = yield* tool.execute({ action: "status" }, ctx()).pipe(Effect.provide(goalLayer)) + + expect(result.output).not.toContain("not available in this runtime") + expect(result.output).toContain("No autonomous goal") + }), + ) + + it.instance("status renders state when an active goal is loaded", () => + Effect.gen(function* () { + const info = yield* GoalTool + const tool = yield* info.init() + const goalLayer = Layer.mock(Goal.Service, { + load: () => Effect.succeed(activeGoal), + }) + + const result = yield* tool.execute({ action: "status" }, ctx()).pipe(Effect.provide(goalLayer)) + + expect(result.output).toContain("Goal: read the docs") + expect(result.output).toContain("Status: active") + expect(result.output).toContain("Turns: 2/20") + }), + ) + + it.instance("complete calls markDone and clears goal state (regression guard)", () => + Effect.gen(function* () { + let markDoneCalls = 0 + const info = yield* GoalTool + const tool = yield* info.init() + const goalLayer = Layer.mock(Goal.Service, { + load: () => Effect.succeed(activeGoal), + markDone: () => + Effect.sync(() => { + markDoneCalls += 1 + return doneGoal + }), + }) + + const result = yield* tool.execute({ action: "complete", reason: "docs read" }, ctx()).pipe( + Effect.provide(goalLayer), + ) + + expect(markDoneCalls).toBe(1) + expect(result.output).toContain("็›ฎๆ ‡ๅทฒ่พพๆˆ") + expect(result.output).toContain("docs read") + }), + ) + + it.instance("complete refuses to complete when no active goal is loaded", () => + Effect.gen(function* () { + const info = yield* GoalTool + const tool = yield* info.init() + const goalLayer = Layer.mock(Goal.Service, { + load: () => Effect.succeed(undefined), + }) + + const result = yield* tool.execute({ action: "complete", reason: "nothing to do" }, ctx()).pipe( + Effect.provide(goalLayer), + ) + + expect(markDoneNeverCalled(result.output)) + expect(result.output).toContain("Cannot complete goal: no active goal") + }), + ) + + it.instance("status degrades gracefully when Goal.Service is absent (headless)", () => + Effect.gen(function* () { + const info = yield* GoalTool + const tool = yield* info.init() + + // No Goal.Service provided at execute time โ€” e.g. a headless runtime. + const result = yield* tool.execute({ action: "status" }, ctx()) + + expect(result.output).toContain("not available in this runtime") + }), + ) +}) + +function markDoneNeverCalled(output: string) { + return !output.includes("็›ฎๆ ‡ๅทฒ่พพๆˆ") +}