@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.
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/package.json +29 -0
- 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
|
+
}
|