@monks1975/opencode-agent-hooks 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +76 -0
  3. package/package.json +29 -0
  4. package/src/index.ts +602 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jonathan Hart
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # opencode-agent-hooks
2
+
3
+ OpenCode plugin that runs a project's Claude Code or Codex hook configs unmodified. If your repo already guards, formats, and gates through `.claude/settings.json` (or the same schema in `.codex/hooks.json`), this plugin makes those hooks fire inside OpenCode too — same commands, same stdin JSON, same exit-code semantics.
4
+
5
+ ## Use
6
+
7
+ Add the plugin to a project's `opencode.json`, or to `~/.config/opencode/opencode.json` to enable it everywhere:
8
+
9
+ ```json
10
+ {
11
+ "$schema": "https://opencode.ai/config.json",
12
+ "plugin": ["@monks1975/opencode-agent-hooks@1.0.0"]
13
+ }
14
+ ```
15
+
16
+ OpenCode installs it on startup. No `npm install` in the project, no other setup. If the project also carries a copy of this plugin in `.opencode/plugins/`, remove it — otherwise every hook runs twice.
17
+
18
+ ## Config sources
19
+
20
+ Lowest precedence first; hook arrays are concatenated (later files add hooks, they don't replace):
21
+
22
+ 1. `~/.claude/settings.json` (honours `$CLAUDE_CONFIG_DIR`)
23
+ 2. `<project>/.claude/settings.json`
24
+ 3. `<project>/.claude/settings.local.json`
25
+ 4. `<project>/.opencode/hooks.json` (same schema; OpenCode-only extras live here)
26
+
27
+ If neither project `.claude` file defines hooks, `<project>/.codex/hooks.json` is read in their place. It is a fallback, not an additional source: a project carrying both configs would otherwise run every hook twice.
28
+
29
+ ## Event mapping
30
+
31
+ | Claude event | OpenCode hook | Notes |
32
+ | --- | --- | --- |
33
+ | `PreToolUse` | `tool.execute.before` | block (exit 2 / `permissionDecision:"deny"`), `updatedInput` |
34
+ | `PostToolUse` | `tool.execute.after` | exit-2 stderr and `additionalContext` append to the tool output; `updatedToolOutput` replaces it |
35
+ | `Stop` | `session.idle` event | soft re-prompt loop, see deviations |
36
+ | `UserPromptSubmit` | `chat.message` | stdout / `additionalContext` injected as a text part |
37
+ | `SessionStart` | `session.created` event | context buffered, delivered with the first message |
38
+ | `PreCompact` | `experimental.session.compacting` | context only |
39
+
40
+ Matched hooks run in parallel with identical commands deduplicated, as under Claude Code. Tool names are normalized to Claude's (`bash` -> `Bash`, `mcp__*` passes through), so matchers like `Write|Edit` work as written.
41
+
42
+ ## What hooks receive
43
+
44
+ Each command gets the full Claude-schema payload on stdin: `session_id`, `cwd`, `hook_event_name`, `tool_name`, snake_case `tool_input` (OpenCode's `filePath` becomes `file_path`), `tool_response` on PostToolUse, `stop_hook_active` on Stop, `prompt` on UserPromptSubmit. Scripts doing `jq -r '.tool_input.file_path'` work unmodified. `transcript_path` is present but always empty; transcript synthesis is not implemented.
45
+
46
+ The JSON output protocol is honoured: `hookSpecificOutput.permissionDecision` (+ reason), `updatedInput`, `updatedToolOutput`, `additionalContext`, legacy `decision`/`reason`, and `continue`/`stopReason`.
47
+
48
+ ## Deviations from Claude Code
49
+
50
+ Forced by OpenCode's model, and documented in the source header:
51
+
52
+ - `session.idle` fires after the agent goes idle, so Stop hooks cannot hard-block. A failing Stop hook re-prompts the session with its stderr instead. Re-entry rounds set `stop_hook_active: true`, exactly like Claude; a per-session round cap is the backstop for hooks that ignore the flag.
53
+ - `permissionDecision: "ask"` and `"allow"` are no-ops: OpenCode's own permission flow has already run and cannot be re-opened or bypassed from a plugin.
54
+ - `continue: false` cannot abort a session. It blocks (PreToolUse), appends the `stopReason` (PostToolUse), or suppresses the re-prompt chain (Stop).
55
+ - UserPromptSubmit blocking throws from `chat.message`, which OpenCode does not document as a reject channel. Context injection, the common case, is unaffected.
56
+
57
+ ## failClosed
58
+
59
+ By default a hook that crashes or times out is ignored (fail-open), matching Claude's non-blocking errors. For security guards that is the wrong default. Mark a hook `"failClosed": true` in `.opencode/hooks.json` and executor errors become a PreToolUse deny. Re-declaring the identical command string there merges the flag via dedup instead of running the hook twice.
60
+
61
+ ## Requirements
62
+
63
+ - macOS or Linux: commands run via `bash -c` with process-group timeout kills.
64
+ - Hook timeouts are per-command, in seconds, from the config (`"timeout": 60`).
65
+
66
+ ## Development
67
+
68
+ ```
69
+ node --test src/index.test.ts
70
+ ```
71
+
72
+ Node 23+ runs the TypeScript directly; Node 22.6+ needs `--experimental-strip-types`. The suite drives the plugin through its exported entry point with throwaway project fixtures; no build step and no dependencies.
73
+
74
+ ## License
75
+
76
+ MIT
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@monks1975/opencode-agent-hooks",
3
+ "version": "1.0.0",
4
+ "description": "OpenCode plugin that runs Claude Code and Codex hook configs unmodified",
5
+ "type": "module",
6
+ "exports": "./src/index.ts",
7
+ "files": [
8
+ "src/index.ts"
9
+ ],
10
+ "scripts": {
11
+ "test": "node --test src/index.test.ts"
12
+ },
13
+ "devDependencies": {
14
+ "@opencode-ai/plugin": "^1.17.12"
15
+ },
16
+ "keywords": [
17
+ "opencode",
18
+ "opencode-plugin",
19
+ "claude-code",
20
+ "codex",
21
+ "hooks"
22
+ ],
23
+ "author": "Jonathan Hart",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/monks1975/opencode-agent-hooks.git"
27
+ },
28
+ "license": "MIT"
29
+ }
package/src/index.ts ADDED
@@ -0,0 +1,602 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+ import { spawn } from "node:child_process"
3
+ import { existsSync, readFileSync } from "node:fs"
4
+ import { homedir } from "node:os"
5
+ import { join } from "node:path"
6
+
7
+ //
8
+ // Generic OpenCode adapter for the Claude Code hook schema.
9
+ //
10
+ // This plugin carries NO project-specific wiring. It reads whatever hooks a
11
+ // project declares for Claude Code (`.claude/settings.json`) or Codex
12
+ // (`.codex/hooks.json`, same schema) and interprets them against OpenCode's
13
+ // event model. Drop it into any project's `.opencode/plugins/` and it runs
14
+ // that project's hooks.
15
+ //
16
+ // Event mapping (Claude -> OpenCode):
17
+ // PreToolUse -> tool.execute.before (deny-wins block, updatedInput)
18
+ // PostToolUse -> tool.execute.after (feedback/context appended to the
19
+ // tool output, updatedToolOutput)
20
+ // Stop -> event: session.idle (soft re-prompt loop, see below)
21
+ // UserPromptSubmit -> chat.message (stdout/additionalContext injected
22
+ // as a text part; block is best-effort)
23
+ // SessionStart -> event: session.created (context buffered, delivered with
24
+ // the first chat.message)
25
+ // PreCompact -> experimental.session.compacting (context only; custom prompt not set)
26
+ //
27
+ // Hooks matched for an event run IN PARALLEL with identical commands
28
+ // deduplicated, as under Claude Code. Every command is fed a full Claude-schema
29
+ // JSON payload on stdin (session_id, cwd, hook_event_name, snake_case
30
+ // tool_input, tool_response, ...), so both this repo's
31
+ // `hooks/adapters/claude-stdin.sh` and third-party hooks doing
32
+ // `jq -r '.tool_input.file_path'` work unmodified. `transcript_path` is present
33
+ // but always "" — transcript synthesis is deliberately not implemented.
34
+ //
35
+ // Documented deviations from Claude Code, all forced by OpenCode's model:
36
+ // 1. session.idle is NOT a blocking Stop hook; it fires AFTER the agent goes
37
+ // idle. When a Stop-phase command fails we re-inject its stderr as a
38
+ // prompt so the model resumes and fixes it. Faithful to Claude, re-entry
39
+ // rounds set stop_hook_active:true (scripts own their loop guard); the
40
+ // per-session round counter stays as a backstop for hooks that ignore it.
41
+ // 2. permissionDecision:"ask" cannot open a user prompt from
42
+ // tool.execute.before; OpenCode's own permission flow has already run, so
43
+ // "ask" defers to it (no-op, logged). "allow" likewise cannot bypass
44
+ // OpenCode permissions.
45
+ // 3. continue:false cannot abort a session; it blocks (PreToolUse), appends
46
+ // stopReason (PostToolUse), or suppresses the re-prompt chain (Stop).
47
+ // 4. UserPromptSubmit blocking relies on throwing from chat.message, which
48
+ // OpenCode does not document as a reject channel; context injection (the
49
+ // common case) is solid either way.
50
+ //
51
+ // Config sources, lowest precedence first; hook arrays are concatenated (later
52
+ // files ADD hooks, they don't replace): user-level Claude settings, then the
53
+ // project's `.claude/settings.json` + `.claude/settings.local.json`, then
54
+ // `.opencode/hooks.json`. If NEITHER project `.claude` file defines hooks,
55
+ // `.codex/hooks.json` is read in their place — a fallback, not an additional
56
+ // source, because a project carrying both configs would run every hook twice
57
+ // (the command strings usually differ textually, so dedup cannot catch it).
58
+ // The user-level dir honours $CLAUDE_CONFIG_DIR like Claude Code does.
59
+ // `.opencode/hooks.json` is also
60
+ // where the OpenCode-only per-hook `failClosed` flag belongs (Claude Code may
61
+ // warn on unknown keys in `.claude/settings.json`); re-declaring an identical
62
+ // command there merges the flag via dedup instead of running it twice.
63
+ // `failClosed: true` turns executor errors (spawn failure, timeout, crash)
64
+ // into a deny for PreToolUse guards; the default stays fail-open.
65
+
66
+ type ClaudeEvent =
67
+ | "PreToolUse"
68
+ | "PostToolUse"
69
+ | "Stop"
70
+ | "UserPromptSubmit"
71
+ | "SessionStart"
72
+ | "PreCompact"
73
+
74
+ type HookCommand = { type?: string; command: string; timeout?: number; failClosed?: boolean }
75
+ type MatcherGroup = { matcher?: string; hooks?: HookCommand[] }
76
+ type HookConfig = Partial<Record<ClaudeEvent, MatcherGroup[]>>
77
+
78
+ type RunResult = { code: number; stdout: string; stderr: string }
79
+ type Logger = (message: string, level?: "info" | "warn" | "error") => void
80
+
81
+ // Normalized shape of a hook's JSON stdout (Claude's structured protocol).
82
+ type HookJson = {
83
+ continue?: boolean
84
+ stopReason?: string
85
+ decision?: string // legacy top-level: "approve" | "block"
86
+ reason?: string
87
+ hookSpecificOutput?: {
88
+ permissionDecision?: "allow" | "deny" | "ask"
89
+ permissionDecisionReason?: string
90
+ additionalContext?: string
91
+ updatedInput?: Record<string, unknown>
92
+ updatedToolOutput?: unknown
93
+ }
94
+ }
95
+
96
+ type PreDecision = {
97
+ kind: "deny" | "ask" | "allow" | "neutral"
98
+ reason?: string
99
+ updatedInput?: Record<string, unknown>
100
+ }
101
+
102
+ const EVENTS: ClaudeEvent[] = [
103
+ "PreToolUse",
104
+ "PostToolUse",
105
+ "Stop",
106
+ "UserPromptSubmit",
107
+ "SessionStart",
108
+ "PreCompact",
109
+ ]
110
+
111
+ const MAX_IDLE_FIX_ROUNDS = 3
112
+ const TIMEOUT_CODE = 124 // conventional timeout exit code; treated as failure
113
+ const HARD_KILL_GRACE_MS = 2_000
114
+
115
+ // OpenCode tool name -> Claude tool name (what matchers are written against).
116
+ // mcp__* names pass through unchanged; anything unknown is Title-cased so novel
117
+ // tools still get a stable, matchable name.
118
+ const TOOL_NAMES: Record<string, string> = {
119
+ bash: "Bash",
120
+ write: "Write",
121
+ edit: "Edit",
122
+ read: "Read",
123
+ glob: "Glob",
124
+ grep: "Grep",
125
+ webfetch: "WebFetch",
126
+ task: "Task",
127
+ todowrite: "TodoWrite",
128
+ todoread: "TodoRead",
129
+ patch: "Patch",
130
+ }
131
+
132
+ function claudeToolName(tool: string): string {
133
+ if (tool.startsWith("mcp__")) return tool
134
+ return TOOL_NAMES[tool] ?? tool.charAt(0).toUpperCase() + tool.slice(1)
135
+ }
136
+
137
+ function matches(matcher: string | undefined, toolName: string): boolean {
138
+ if (!matcher) return true // empty matcher = all tools (Claude semantics)
139
+ try {
140
+ return new RegExp(`^(?:${matcher})$`).test(toolName)
141
+ } catch {
142
+ return false // a bad matcher matches nothing rather than throwing
143
+ }
144
+ }
145
+
146
+ // Shallow key reshaping only: Claude's tool_input keys are top-level snake_case
147
+ // (file_path, old_string, ...). Reshaping nested values would corrupt user data
148
+ // (e.g. todo objects), so values pass through untouched.
149
+ function toSnakeKeys(obj: Record<string, unknown>): Record<string, unknown> {
150
+ const out: Record<string, unknown> = {}
151
+ for (const [k, v] of Object.entries(obj)) {
152
+ out[k.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase()] = v
153
+ }
154
+ return out
155
+ }
156
+
157
+ function toCamelKeys(obj: Record<string, unknown>): Record<string, unknown> {
158
+ const out: Record<string, unknown> = {}
159
+ for (const [k, v] of Object.entries(obj)) {
160
+ out[k.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase())] = v
161
+ }
162
+ return out
163
+ }
164
+
165
+ // Parse a hook's JSON stdout. Tolerant of surrounding noise (a formatter that
166
+ // prints "1 file reformatted" before its JSON blob is not malformed).
167
+ function parseHookJson(stdout: string): HookJson | undefined {
168
+ const text = stdout.trim()
169
+ if (!text) return undefined
170
+ const candidates = [text]
171
+ const first = text.indexOf("{")
172
+ const last = text.lastIndexOf("}")
173
+ if (first > 0 && last > first) candidates.push(text.slice(first, last + 1))
174
+ for (const c of candidates) {
175
+ try {
176
+ const parsed: unknown = JSON.parse(c)
177
+ if (parsed && typeof parsed === "object") return parsed as HookJson
178
+ } catch {
179
+ // not JSON, try the next candidate
180
+ }
181
+ }
182
+ return undefined
183
+ }
184
+
185
+ // Short display name for a hook command, for feedback labels and logs. Prefers
186
+ // the last token (the script name in the "adapter.sh script.sh" form).
187
+ function hookLabel(command: string): string {
188
+ const tokens = command.trim().split(/\s+/)
189
+ const last = (tokens[tokens.length - 1] ?? "").replace(/["']/g, "")
190
+ const pick = /[/.]/.test(last) ? last : (tokens[0] ?? "").replace(/["']/g, "")
191
+ return pick.slice(pick.lastIndexOf("/") + 1) || command
192
+ }
193
+
194
+ // Per-hook PreToolUse classification. Deny beats everything; executor errors
195
+ // (timeout, crash, spawn failure) deny only when the hook is marked failClosed,
196
+ // otherwise they stay fail-open like Claude's non-blocking errors.
197
+ function classifyPre(r: RunResult, failClosed: boolean): PreDecision {
198
+ if (r.code === 2) return { kind: "deny", reason: r.stderr.trim() || "Blocked by hook" }
199
+ if (r.code !== 0) {
200
+ return failClosed
201
+ ? { kind: "deny", reason: `hook failed closed (exit ${r.code}): ${r.stderr.trim() || "no stderr"}` }
202
+ : { kind: "neutral" }
203
+ }
204
+ const json = parseHookJson(r.stdout)
205
+ if (!json) return { kind: "neutral" }
206
+ const out = json.hookSpecificOutput
207
+ if (out?.permissionDecision === "deny") {
208
+ return { kind: "deny", reason: out.permissionDecisionReason ?? "Denied by hook" }
209
+ }
210
+ if (json.decision === "block") return { kind: "deny", reason: json.reason ?? "Blocked by hook" }
211
+ if (json.continue === false) return { kind: "deny", reason: json.stopReason ?? "Stopped by hook" }
212
+ if (out?.permissionDecision === "ask") return { kind: "ask" }
213
+ if (out?.permissionDecision === "allow") return { kind: "allow", updatedInput: out.updatedInput }
214
+ return { kind: "neutral", updatedInput: out?.updatedInput }
215
+ }
216
+
217
+ // Claude Code deduplicates identical hook commands. Merging failClosed with OR
218
+ // (and timeout with max) lets a project re-declare a `.claude/settings.json`
219
+ // command in `.opencode/hooks.json` solely to mark it fail-closed.
220
+ function dedupeHooks(hooks: HookCommand[]): HookCommand[] {
221
+ const byCommand = new Map<string, HookCommand>()
222
+ for (const h of hooks) {
223
+ const prev = byCommand.get(h.command)
224
+ if (!prev) {
225
+ byCommand.set(h.command, { ...h })
226
+ } else {
227
+ prev.failClosed = prev.failClosed || h.failClosed
228
+ if (h.timeout && (!prev.timeout || h.timeout > prev.timeout)) prev.timeout = h.timeout
229
+ }
230
+ }
231
+ return [...byCommand.values()]
232
+ }
233
+
234
+ function readHooks(path: string, log: Logger): HookConfig | undefined {
235
+ if (!existsSync(path)) return undefined
236
+ try {
237
+ return (JSON.parse(readFileSync(path, "utf8")) as { hooks?: HookConfig }).hooks
238
+ } catch (e) {
239
+ log(`ignoring malformed hook config ${path}: ${e}`, "warn")
240
+ return undefined
241
+ }
242
+ }
243
+
244
+ function mergeHooks(into: HookConfig, from: HookConfig | undefined): void {
245
+ if (!from) return
246
+ for (const event of EVENTS) {
247
+ const groups = from[event]
248
+ if (Array.isArray(groups)) into[event] = [...(into[event] ?? []), ...groups]
249
+ }
250
+ }
251
+
252
+ function hasHooks(config: HookConfig | undefined): boolean {
253
+ return EVENTS.some((event) => (config?.[event]?.length ?? 0) > 0)
254
+ }
255
+
256
+ // See the header for the source order and the .codex fallback rule.
257
+ function loadHookConfig(projectDir: string, userConfigDir: string, log: Logger): HookConfig {
258
+ const claudeProject = [
259
+ readHooks(join(projectDir, ".claude/settings.json"), log),
260
+ readHooks(join(projectDir, ".claude/settings.local.json"), log),
261
+ ]
262
+ const merged: HookConfig = {}
263
+ mergeHooks(merged, readHooks(join(userConfigDir, "settings.json"), log))
264
+ if (claudeProject.some(hasHooks)) {
265
+ for (const c of claudeProject) mergeHooks(merged, c)
266
+ } else {
267
+ mergeHooks(merged, readHooks(join(projectDir, ".codex/hooks.json"), log))
268
+ }
269
+ mergeHooks(merged, readHooks(join(projectDir, ".opencode/hooks.json"), log))
270
+ return merged
271
+ }
272
+
273
+ // Full Claude-schema stdin payload. The common fields are on every event;
274
+ // transcript_path is present-but-empty so `jq -r '.transcript_path'` stays
275
+ // well-formed (synthesis deliberately skipped).
276
+ function buildPayload(
277
+ event: ClaudeEvent,
278
+ cwd: string,
279
+ sessionID: string | undefined,
280
+ fields: Record<string, unknown>,
281
+ ): string {
282
+ return JSON.stringify({
283
+ session_id: sessionID ?? "",
284
+ transcript_path: "",
285
+ cwd,
286
+ hook_event_name: event,
287
+ ...fields,
288
+ })
289
+ }
290
+
291
+ // Run a config command string via `bash -c` so $CLAUDE_PROJECT_DIR and the
292
+ // "adapter.sh script.sh" two-token form expand. detached:true makes the child a
293
+ // process-group leader so a timeout kills the whole tree (script and everything it spawned).
294
+ function runCommand(
295
+ command: string,
296
+ stdin: string,
297
+ opts: { cwd: string; env: Record<string, string>; timeoutMs?: number },
298
+ ): Promise<RunResult> {
299
+ return new Promise((resolve) => {
300
+ const child = spawn("bash", ["-c", command], {
301
+ cwd: opts.cwd,
302
+ env: opts.env,
303
+ detached: true,
304
+ })
305
+
306
+ let out = ""
307
+ let err = ""
308
+ child.stdout?.on("data", (c) => { out += c.toString() })
309
+ child.stderr?.on("data", (c) => { err += c.toString() })
310
+
311
+ // A closed pipe (adapter exits before reading all stdin) must not crash us.
312
+ child.stdin?.on("error", () => {})
313
+ try {
314
+ child.stdin?.write(stdin)
315
+ child.stdin?.end()
316
+ } catch {
317
+ // group already gone
318
+ }
319
+
320
+ const killGroup = (signal: string) => {
321
+ if (child.pid === undefined) return
322
+ try {
323
+ process.kill(-child.pid, signal)
324
+ } catch {
325
+ // group already exited
326
+ }
327
+ }
328
+
329
+ let timedOut = false
330
+ let softTimer: ReturnType<typeof setTimeout> | undefined
331
+ let hardTimer: ReturnType<typeof setTimeout> | undefined
332
+ if (opts.timeoutMs) {
333
+ softTimer = setTimeout(() => {
334
+ timedOut = true
335
+ killGroup("SIGTERM")
336
+ hardTimer = setTimeout(() => killGroup("SIGKILL"), HARD_KILL_GRACE_MS)
337
+ }, opts.timeoutMs)
338
+ }
339
+
340
+ let settled = false
341
+ const finish = (code: number) => {
342
+ if (settled) return
343
+ settled = true
344
+ if (softTimer) clearTimeout(softTimer)
345
+ if (hardTimer) clearTimeout(hardTimer)
346
+ if (timedOut) {
347
+ resolve({ code: TIMEOUT_CODE, stdout: out, stderr: err || `timed out after ${opts.timeoutMs}ms (process group killed)` })
348
+ } else {
349
+ resolve({ code, stdout: out, stderr: err })
350
+ }
351
+ }
352
+ child.on("close", (c) => finish(c ?? 0))
353
+ child.on("error", () => finish(1))
354
+ })
355
+ }
356
+
357
+ export const server: Plugin = async ({ directory, worktree, client }) => {
358
+ const projectDir = worktree ?? directory
359
+ const log: Logger = (message, level = "info") =>
360
+ client.app.log({ body: { service: "claude-hooks", level, message } }).catch(() => {})
361
+
362
+ const userConfigDir = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude")
363
+ const config = loadHookConfig(projectDir, userConfigDir, log)
364
+ const env = { ...process.env, CLAUDE_PROJECT_DIR: projectDir }
365
+
366
+ // toolName undefined = lifecycle event; Claude ignores matchers there.
367
+ const matchingHooks = (event: ClaudeEvent, toolName?: string): HookCommand[] =>
368
+ (config[event] ?? [])
369
+ .filter((g) => toolName === undefined || matches(g.matcher, toolName))
370
+ .flatMap((g) => g.hooks ?? [])
371
+ .filter((h) => !h.type || h.type === "command")
372
+
373
+ const run = (hook: HookCommand, stdin: string) =>
374
+ runCommand(hook.command, stdin, {
375
+ cwd: projectDir,
376
+ env,
377
+ timeoutMs: hook.timeout ? hook.timeout * 1000 : undefined, // Claude timeout is seconds
378
+ })
379
+
380
+ // Dedupe then run in parallel (Claude behavior); results keep config order so
381
+ // decision application stays deterministic.
382
+ const runHooks = async (hooks: HookCommand[], stdin: string) => {
383
+ const deduped = dedupeHooks(hooks)
384
+ const results = await Promise.all(deduped.map((hook) => run(hook, stdin)))
385
+ return deduped.map((hook, i) => ({ hook, result: results[i] }))
386
+ }
387
+
388
+ const pushText = (parts: unknown[], text: string) => parts.push({ type: "text", text })
389
+
390
+ // Per-session Stop re-entrancy guard (see header deviation 1). `stopRunning`
391
+ // prevents overlapping idle chains.
392
+ const idleRounds = new Map<string, number>()
393
+ let stopRunning = false
394
+ // SessionStart hook output, buffered until the session's first chat.message.
395
+ const pendingSessionContext = new Map<string, string>()
396
+
397
+ return {
398
+ "tool.execute.before": async (input, output) => {
399
+ const toolName = claudeToolName(input.tool)
400
+ const hooks = matchingHooks("PreToolUse", toolName)
401
+ if (!hooks.length) return
402
+ const args = (output.args ?? {}) as Record<string, unknown>
403
+ const stdin = buildPayload("PreToolUse", projectDir, input.sessionID, {
404
+ tool_name: toolName,
405
+ tool_input: toSnakeKeys(args),
406
+ })
407
+ const results = await runHooks(hooks, stdin)
408
+ const decisions = results.map(({ hook, result }) => ({
409
+ hook,
410
+ decision: classifyPre(result, hook.failClosed ?? false),
411
+ }))
412
+
413
+ // Deny-wins aggregation, all reasons surfaced together.
414
+ const denies = decisions.filter((d) => d.decision.kind === "deny")
415
+ if (denies.length) throw new Error(denies.map((d) => d.decision.reason).join("\n"))
416
+ for (const d of decisions) {
417
+ if (d.decision.kind === "ask" || d.decision.kind === "allow") {
418
+ log(`${hookLabel(d.hook.command)} returned permissionDecision:"${d.decision.kind}"; deferring to OpenCode's own permission flow`)
419
+ }
420
+ }
421
+
422
+ // updatedInput replaces tool_input wholesale (Claude semantics); last
423
+ // supplier in config order wins. Mutate output.args in place — OpenCode
424
+ // holds the object reference.
425
+ const updated = decisions.filter((d) => d.decision.updatedInput).pop()?.decision.updatedInput
426
+ if (updated && output.args && typeof output.args === "object") {
427
+ const args = output.args as Record<string, unknown>
428
+ for (const k of Object.keys(args)) delete args[k]
429
+ Object.assign(args, toCamelKeys(updated))
430
+ }
431
+ },
432
+
433
+ "tool.execute.after": async (input, output) => {
434
+ const toolName = claudeToolName(input.tool)
435
+ const hooks = matchingHooks("PostToolUse", toolName)
436
+ if (!hooks.length) return
437
+ const args = (input.args ?? {}) as Record<string, unknown>
438
+ if ((input.tool === "write" || input.tool === "edit") && !args.filePath && !args.file_path) {
439
+ log(`${input.tool}: no file path in args; PostToolUse hooks got an empty path`, "warn")
440
+ }
441
+ const stdin = buildPayload("PostToolUse", projectDir, input.sessionID, {
442
+ tool_name: toolName,
443
+ tool_input: toSnakeKeys(args),
444
+ // Approximation: Claude's per-tool tool_response shapes can't be replicated.
445
+ tool_response: {
446
+ ...toSnakeKeys((output.metadata ?? {}) as Record<string, unknown>),
447
+ output: output.output,
448
+ },
449
+ })
450
+ const results = await runHooks(hooks, stdin)
451
+
452
+ // Everything a hook has to say rides the tool output itself, so the model
453
+ // sees it on the same turn (Claude's "stderr fed back" semantics).
454
+ let replaced = 0
455
+ for (const { hook, result } of results) {
456
+ const json = parseHookJson(result.stdout)
457
+ const updatedOutput = json?.hookSpecificOutput?.updatedToolOutput
458
+ if (updatedOutput !== undefined) {
459
+ if (replaced++) log(`multiple hooks replaced the tool output; ${hookLabel(hook.command)} wins`, "warn")
460
+ output.output = typeof updatedOutput === "string" ? updatedOutput : JSON.stringify(updatedOutput)
461
+ }
462
+ if (result.code === 2 || json?.decision === "block") {
463
+ const reason = result.code === 2 ? result.stderr.trim() : json?.reason
464
+ output.output += `\n\n[hook feedback: ${hookLabel(hook.command)}]\n${reason || "hook reported a problem"}`
465
+ } else if (result.stderr.trim()) {
466
+ log(`${hook.command}: ${result.stderr.trim()}`, "warn")
467
+ }
468
+ const ctx = json?.hookSpecificOutput?.additionalContext
469
+ if (typeof ctx === "string" && ctx.trim()) output.output += `\n\n[hook context]\n${ctx}`
470
+ if (json?.continue === false) {
471
+ output.output += `\n\n[hook stop request]\n${json.stopReason ?? "A hook requested the agent stop."}`
472
+ log(`${hookLabel(hook.command)} requested continue:false (session abort unsupported; stopReason appended)`, "warn")
473
+ }
474
+ }
475
+ },
476
+
477
+ "chat.message": async (input, output) => {
478
+ const pending = input.sessionID ? pendingSessionContext.get(input.sessionID) : undefined
479
+ if (pending) {
480
+ pendingSessionContext.delete(input.sessionID)
481
+ pushText(output.parts, `<session-start-hook>\n${pending}\n</session-start-hook>`)
482
+ }
483
+
484
+ const hooks = matchingHooks("UserPromptSubmit")
485
+ if (!hooks.length) return
486
+ const prompt = output.parts
487
+ .filter((p): p is { type: "text"; text: string } =>
488
+ (p as { type?: string }).type === "text" && typeof (p as { text?: unknown }).text === "string")
489
+ .map((p) => p.text)
490
+ .join("\n")
491
+ const stdin = buildPayload("UserPromptSubmit", projectDir, input.sessionID, { prompt })
492
+ const results = await runHooks(hooks, stdin)
493
+
494
+ const blocks: string[] = []
495
+ const contexts: string[] = []
496
+ for (const { hook, result } of results) {
497
+ const json = parseHookJson(result.stdout)
498
+ if (result.code === 2 || json?.decision === "block") {
499
+ blocks.push((result.code === 2 ? result.stderr.trim() : json?.reason) || "Prompt blocked by hook")
500
+ continue
501
+ }
502
+ if (result.code !== 0) {
503
+ log(`${hook.command}: ${result.stderr.trim() || `exit ${result.code}`}`, "warn")
504
+ continue
505
+ }
506
+ const ctx = json?.hookSpecificOutput?.additionalContext
507
+ if (typeof ctx === "string" && ctx.trim()) contexts.push(ctx.trim())
508
+ // UserPromptSubmit is the one event where plain stdout injects as context.
509
+ else if (!json && result.stdout.trim()) contexts.push(result.stdout.trim())
510
+ }
511
+ // Best-effort block (header deviation 4): chat.message has no documented
512
+ // reject channel. If OpenCode surfaces the throw badly, the fallback is to
513
+ // replace the prompt text in output.parts with the reason instead.
514
+ if (blocks.length) throw new Error(blocks.join("\n"))
515
+ if (contexts.length) {
516
+ pushText(output.parts, `<user-prompt-submit-hook>\n${contexts.join("\n")}\n</user-prompt-submit-hook>`)
517
+ }
518
+ },
519
+
520
+ "experimental.session.compacting": async (input, output) => {
521
+ const hooks = matchingHooks("PreCompact")
522
+ if (!hooks.length) return
523
+ const stdin = buildPayload("PreCompact", projectDir, input.sessionID, {
524
+ trigger: "auto",
525
+ custom_instructions: "",
526
+ })
527
+ const results = await runHooks(hooks, stdin)
528
+ for (const { result } of results) {
529
+ const json = parseHookJson(result.stdout)
530
+ const ctx = json?.hookSpecificOutput?.additionalContext
531
+ if (typeof ctx === "string" && ctx.trim()) output.context.push(ctx.trim())
532
+ else if (!json && result.code === 0 && result.stdout.trim()) output.context.push(result.stdout.trim())
533
+ }
534
+ },
535
+
536
+ event: async ({ event }) => {
537
+ if (event.type === "session.created") {
538
+ const hooks = matchingHooks("SessionStart")
539
+ if (!hooks.length) return
540
+ const info = (event.properties as { info?: { id?: string; parentID?: string } }).info
541
+ if (!info?.id || info.parentID) return // subagent sessions don't re-run SessionStart
542
+ const stdin = buildPayload("SessionStart", projectDir, info.id, { source: "startup" })
543
+ const results = await runHooks(hooks, stdin)
544
+ const pieces: string[] = []
545
+ for (const { result } of results) {
546
+ const json = parseHookJson(result.stdout)
547
+ const ctx = json?.hookSpecificOutput?.additionalContext
548
+ if (typeof ctx === "string" && ctx.trim()) pieces.push(ctx.trim())
549
+ else if (!json && result.code === 0 && result.stdout.trim()) pieces.push(result.stdout.trim())
550
+ }
551
+ if (pieces.length) pendingSessionContext.set(info.id, pieces.join("\n"))
552
+ return
553
+ }
554
+
555
+ if (event.type !== "session.idle") return
556
+ const hooks = matchingHooks("Stop")
557
+ if (!hooks.length || stopRunning) return
558
+ const sessionID = (event.properties as { sessionID?: string }).sessionID
559
+ if (!sessionID) return
560
+ const round = idleRounds.get(sessionID) ?? 0
561
+ if (round >= MAX_IDLE_FIX_ROUNDS) {
562
+ log(`idle fix rounds exhausted (${round}); leaving session alone`, "warn")
563
+ return
564
+ }
565
+
566
+ stopRunning = true
567
+ try {
568
+ const stdin = buildPayload("Stop", projectDir, sessionID, { stop_hook_active: round > 0 })
569
+ const results = await runHooks(hooks, stdin)
570
+ const failures: string[] = []
571
+ let suppress = false
572
+ for (const { hook, result } of results) {
573
+ const json = parseHookJson(result.stdout)
574
+ // Any non-zero is "not clean": 2 = the script's veto, 124 = timeout,
575
+ // anything else = it crashed. decision:"block" vetoes even on exit 0.
576
+ if (result.code !== 0) failures.push(result.stderr.trim() || `${hook.command} failed`)
577
+ else if (json?.decision === "block") failures.push(json.reason ?? `${hookLabel(hook.command)} blocked completion`)
578
+ if (json?.continue === false) suppress = true
579
+ }
580
+ if (suppress) {
581
+ idleRounds.set(sessionID, MAX_IDLE_FIX_ROUNDS)
582
+ log("a Stop hook returned continue:false; suppressing re-prompts for this chain", "warn")
583
+ } else if (failures.length) {
584
+ idleRounds.set(sessionID, round + 1)
585
+ await client.session.prompt({
586
+ path: { id: sessionID },
587
+ body: {
588
+ parts: [{
589
+ type: "text",
590
+ text: "Completion checks failed. Please fix the following before finishing:\n\n" + failures.join("\n\n"),
591
+ }],
592
+ },
593
+ }).catch((e) => log(`re-prompt failed: ${e}`, "warn"))
594
+ } else {
595
+ idleRounds.delete(sessionID)
596
+ }
597
+ } finally {
598
+ stopRunning = false
599
+ }
600
+ },
601
+ }
602
+ }