1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
= "opencode"
= "opencode"
= "npm install -g opencode-ai"
= ["$HOME/.local/share/opencode/"]
# What proves a login: a harness fills its config dir just by starting.
= ["$HOME/.local/share/opencode/auth.json"]
= "run `/login` inside opencode, then exit"
[]
= "/work/AGENTS.md"
= "concat"
[]
= "$HOME/.config/opencode/skills"
= "dir"
[]
= "$HOME/.config/opencode/opencode.json"
= "opencode-json"
= "$HOME/.config/opencode/opencode.json"
# Plural, per https://opencode.ai/docs/commands/ read 2026-08-12. omh spelled
# this `command` and mounted every custom command where nothing reads it —
# singular is accepted only as a legacy alias. No test can catch a wrong path
# here and `omh doctor` cannot either: its `dir` check verifies the directory
# omh mounted holds what omh put there, which passes either way.
[]
= "$HOME/.config/opencode/commands"
= "dir"
# Agent markdown files, `mode: subagent` in the frontmatter. Plural, per
# https://opencode.ai/docs/agents/ read 2026-08-12.
#
# omh declared none here and dropped them at every launch, which cost opencode
# users a feature they had — the capability floor `docs/design/decisions.md`
# calls out. That the mount lands is checked by `omh doctor`; that opencode
# reads what is in it is a claim about external software, and the frontmatter
# it accepts is *not* the same as Claude's — see `docs/design/profile.md`.
[]
= "$HOME/.config/opencode/agents"
= "dir"
# How this harness names the things an agent does. Verified by running one:
# `tool.execute.before` receives `input.tool`, and a read arrived as `read` with
# `output.args.filePath`, a shell call as `bash` with `output.args.command`.
#
# No `search`: opencode has `grep` and `glob` as separate tools and omh's
# vocabulary has one word for both, so a hook narrowing to `search` is dropped
# by name rather than silently matching half of what it asked for. A map is a
# claim about another program, and half a claim is worse than none.
[]
= "edit"
= "read"
= "bash"
# Hooks are a **plugin**, not a config file: a TypeScript module, auto-loaded
# from `.opencode/plugin/` in a project or `~/.config/opencode/plugin/`
# globally. Both were verified to load, in that image, at this version.
#
# This is why P5 needed a code generator at all. `docs/design/profile.md` said
# the maps would be complete when P3 landed; they are not, because no second
# harness expresses hooks as configuration.
[]
= "$HOME/.config/opencode/plugin/omh.ts"
= "opencode-plugin"
# `before-tool` and `after-tool` are plugin hooks. The other two are *bus event
# types*, dispatched from the catch-all `event` hook — `session.idle` is what
# fires when the agent stops, which is the moment `turn-end` means.
#
# No `session-start`: `session.created` fires, but nothing omh ships for that
# moment can be expressed here anyway — `graph-orient` injects, and there is no
# advisory channel outside a tool result. Mapping it would promise a moment that
# every hook using it would then be dropped from.
[]
= "session.idle"
= "tool.execute.before"
= "tool.execute.after"
# Where this harness keeps each field, as a property of the tool's arguments.
# The same map holds jq paths for Claude Code; each renderer reads it in its own
# language, because "where does this harness keep the file path" has no answer
# that is true in both.
[]
= "filePath"
= "command"
# Advisory text, at the one moment there is a channel for it. The tool's result
# is what the model reads next, so appending to it reaches the model — verified:
# a hook that rewrote a read's output changed the model's answer.
#
# There is deliberately no way to say "advise before the tool runs". That moment
# has only a `throw`, which blocks, and turning a nudge into a wall is the one
# translation omh refuses to make silently.
[]
= 'output.output = String(output.output ?? "") + "\n\n" + {{text}}'
# Blocking, with a reason. Verified: throwing from `tool.execute.before` stops
# the call and the message reaches the model — omh's git notice came back
# paraphrased in the reply.
[]
= "throw new Error({{text}})"