Repository navigation
[kanon-fake-strategist] Render hooks per agent capability and add OpenCode hook support #100
Description
🤖 Kelos Strategist Agent @gjkim42
Latest verdict
- Status:
VALID - Checked at:
2026-10-26T12:03:09Z - Evidence: The Kanon gap still reproduces on the unchanged default branch. Since the 2026-10-26T00:02:39Z check, the only change to a cited contract is an additive OpenCode plugin API that does not touch hooks. The proposal and acceptance criteria are unchanged.
- Kanon:
git fetchconfirms the default branch is still d588121. Besides this issue, only #101 has been updated since the last check, and only to refresh its own verdict. It still covers only a missingkanon.yaml. This issue still has no comments or assignees. I rebuilt withmake buildand re-ran the reproduction below in an emptyHOME, adding atargets: [codex]hook withtype: http:kanon validateprintskanon.yaml is valid.with no warning.kanon render --agent codexexits 0 and writes theNotificationevent, theargsarray, and a bare"type": "http"handler into~/.codex/hooks.json.kanon render --agent opencodeprintsNo target files.in user scope and with--project <dir>, even for the hook withtargets: [opencode].kanon apply --agent opencode --yesprintsNo changes., and no file is written underHOME.
- Codex (unchanged): The latest stable release is still
rust-v0.164.0, and its tag still resolves to 6aaac69. The newest prerelease is stillrust-v0.165.0-alpha.1, andmainis still at 970111f. - OpenCode 2 (no hook contract change):
- npm
latestfor@opencode/cliand@opencode/pluginis still2.1.4, and tagv2.1.4is still 68bc26b. Homebrew Core'sopencodeformula has had no commit since 6374d8f and still installsv2.1.4. devmoved from 08ac673 to ed74ec8 in 432 commits, including a beta-to-dev merge. The compare view truncates the file list, so I compared each cited file's blob SHA at both commits instead. These are unchanged:plugin/module.ts,plugin/source-directory.ts,config/plugin/source.ts,session-status-event.ts,effect/tool.ts,tool/plugin/shell.ts,core/src/plugin.ts,config/plugin/compatibility.ts, and bothmigrate-v1.mdxguides.promise/adapter.tschanged only through #56550, which addsctx.session.instructions.entry(list,put,remove) forsetupplugins. Theexecute.before/execute.aftertool-hook adapter code is identical, only shifted down 16 lines. A rejectedexecute.beforetherefore still fails only its own call, and the pinned 34d8ded link is still accurate.
- npm
- OpenCode 1 (unchanged): npm
latestforopencode-aiis still1.19.4, which is also still GitHub's "Latest" release. - Claude Code (unchanged): The hooks reference still says that when
argsis present,commandis "spawned directly withargsas the argument vector, with no shell involved". It still lists five handler types:command,http,mcp_tool,prompt, andagent. - Maintainer config (unchanged): gjkim42/kanon-repo is still at b30d6f4 (2026-09-09), with five untargeted hooks and no MCP servers.
- Triage and overlap (unchanged):
- Alternatives (unchanged): MCP field fidelity is still weaker than this candidate. The maintainer's config has no MCP servers, and the closest proposal, #14, was closed as not planned. Managing permissions is still ruled out: #18 was closed as not planned, and the README says Kanon does not manage them. This candidate stays.
- Kanon:
Area: New Managed-Settings Types & CLI Extensions
Summary
Hooks are the only managed setting that doesn't reach every shipped agent, and they don't render faithfully where they do. Kanon writes every neutral hook into Codex's hooks.json verbatim, and never renders hooks for OpenCode. The result: shared hooks silently behave differently per agent, and validate/render/diff/apply all succeed.
Proposal: give each adapter an explicit hook capability table. Render a hook only where the agent can run it as written, translate faithfully where possible, and report every omission. Use the same table to add OpenCode hook rendering through one generated local plugin that loads on both OpenCode 1 and OpenCode 2. kanon.yaml needs no schema change.
Why this candidate
- It is the maintainer's own workflow. gjkim42/kanon-repo syncs five untargeted tmux-status hooks (
Stop,Notification,PreToolUse,SessionEnd,UserPromptSubmit). Today Codex ignores theNotificationone, and OpenCode gets none of them. - Silent safety degradation. A
PreToolUseguard written withargs(Claude's exec form) runs in Codex without its arguments, because Codex drops the unknownargskey. - One bad entry disables every Codex hook. Kanon passes
typethrough unchecked. Codex parses the whole file with serde, so one unsupported handler type fails the parse, and Codex skips the entire file with a startup warning. - It completes the provider the maintainer just shipped. #89 added OpenCode for instructions, MCP, and skills. Hooks are the remaining managed type, and
targets: [opencode]is already accepted for hooks without producing anything. - Small, backward-compatible, testable. No new YAML, no network, no new command. Existing Claude output is unchanged.
Reproduction (d588121)
version: 1
hooks:
- name: notification-1
event: Notification
type: command
command: bash -c 'tmux set-window-option window-status-style "bg=yellow"'
- name: guard-bash
event: PreToolUse
matcher: Bash
type: command
command: /usr/local/bin/guard
args: ["--deny", "rm -rf"]
- name: opencode-only
event: Stop
targets: [opencode]
type: command
command: notify-send donekanon validate prints kanon.yaml is valid. and kanon render exits 0, but:
~/.codex/hooks.jsoncontainsNotification, which is not a Codex event, so Codex ignores it.~/.codex/hooks.jsoncontains"args": ["--deny", "rm -rf"]. Codex ignores it and runs/usr/local/bin/guardwith no arguments.- Nothing is rendered for OpenCode, including the hook explicitly targeted at
opencode. - A separate
type: httphook (no URL) also validates and is written tohooks.json. Per Codex's parser, that file then fails to parse and every Codex hook in it is dropped.
Current product gap
Hookhas one neutral shape.hooksForAgentemits the same event/handler map for every agent, filtered only bytargets.- The Codex adapter writes that map to
hooks.jsonwith no event, type, or field check. - The OpenCode adapter renders instructions,
opencode.jsonMCP, and skills only. - Hook validation checks only the name and target names.
- A warning channel already exists:
validateprintswarning: …lines for lock state.
External contracts
Codex (pinned at the latest stable release, rust-v0.164.0. main at 60adfa8 defines the same events and handler variants in codex-rs/protocol/src/hooks.rs):
hooks.jsonaccepts exactlyPreModelRequest,PostModelRequest,PreToolUse,PermissionRequest,PostToolUse,PreCompact,PostCompact,SessionStart,SessionEnd,UserPromptSubmit,SubagentStart,SubagentStop,Stop, andInterrupt(HookEventsToml).PreModelRequestandPostModelRequestare observation-only (#55144). Unknown event keys are ignored because only the top level usesdeny_unknown_fields.- Handler variants are
command(fieldscommand,commandWindows,timeout,async,statusMessage,additionalContextLimit) andmcp_tool.promptandagentare parsed but skipped. Any othertypeis a parse error (HookHandlerConfig). - Command hooks run through a shell, as
<shell> -lc <command>on Unix (command runner). - Codex asks the user to review and trust non-managed hooks before they run (docs).
Claude Code: command hooks accept args, which switches to exec form with no shell (hooks reference). Kanon's neutral shape mirrors Claude's today.
OpenCode 1 (opencode-ai@1.18.35, tag 4ac0d9c; plugin files byte-identical in npm latest 1.19.4):
- Local plugins are auto-loaded from
{plugin,plugins}/*.{ts,js}under the global and project config directories (loader), i.e.~/.config/opencode/plugins/and.opencode/plugins/(docs). - A default export with
idand aserverfunction is loaded as a module plugin. The loader returns that object without checking for other keys, and path plugins must exportid. serverreturns theHooksobject:event(includingsession.createdandsession.idle),chat.message,tool.execute.before, andtool.execute.after. Throwing fromtool.execute.beforeblocks the tool call.- The shell tool's ID stays
bashin OpenCode 1, for compatibility with existing plugins and saved permissions.
OpenCode 2 (@opencode/cli@2.0.26, default branch since 2026-10-10, pinned at 823ac87 unless noted):
- OpenCode 1 and 2 both install the
opencodecommand and use the same config locations. V1 plugin implementations do not run in V2. - Local plugins are still auto-discovered from
plugin/andplugins/. The default export must decode as{ id, effect }or{ id, setup }. - Discovery and config entries are handled differently (pinned at
v2.1.4):- Auto-discovered
.jsand.tsfiles load as file entrypoints. - A local path listed under the
pluginsconfig key must be a directory. A file path there is skipped with a warning (plugin migration guide).
- Auto-discovered
- Hooks are registered in
setup, per the V1-to-V2 hook map:event→ctx.event.subscribe(), which still carriessession.idleandsession.createdchat.message→ctx.session.hook("prompt")tool.execute.beforeandtool.execute.after→ctx.tool.hook("execute.before")andctx.tool.hook("execute.after"). Onlyexecute.beforemay fail, which rejects the call. From@opencode/cli2.0.29 (#55263), a rejectedexecute.beforecallback in asetupplugin fails only that call, with its own message (adapter.ts#L637-L643). In earlier 2.0.x releases, every call rejected in a parallel batch showed the first hook's message.
- The Bun
$helper is not provided in V2. - The shell tool is named
shellin2.0.32through2.1.4, and theexecute.beforeevent carriestoolandinput. The docs useshellsince #56922.
Proposal
1. Per-adapter hook capability table
Each adapter declares what it can render: supported events, handler types, matcher support, and how args is represented. Rendering and validation both read from this table, so they can't disagree. Claude keeps full passthrough in the MVP.
| Claude | Codex | OpenCode (MVP) | |
|---|---|---|---|
| Events | passthrough | the events of the latest stable Codex release (the 14 listed above) | SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop |
Handler type |
passthrough | command |
command |
matcher |
yes | yes | no (the shell tool is Bash in Claude and Codex, bash in OpenCode 1, and shell in OpenCode 2) |
args |
native exec form | POSIX-quoted into command |
exec form in the plugin |
timeout, async |
native | native | enforced by the plugin |
The Codex row tracks the latest stable Codex release. Codex ignores unknown event keys. So if a newer Codex release adds an event before Kanon's row includes it, an implicit-target hook for that event is left out with a warning, and the rest of hooks.json still loads.
2. Explicit versus implicit targets
- Explicit target (
targetsnames the agent) plus an unsupported capability: a validation error that names the hook, the agent, and the unsupported event, type, or field. Examples:hook "guard" targets opencode but event "Notification" is not supported for opencode, orhook "audit" targets codex but type "http" is not supported for codex.render,diff, andapplyalready run validation, so they fail the same way. - Implicit target (
targetsomitted orall): the hook is left out for that agent.validate,render,diff, andapplyprint a warning to stderr naming the hook, the agent, and the reason. Existing configs, including the maintainer's, stay valid, and nothing is dropped silently. Users silence the warning by statingtargets.
3. Faithful Codex translation
- Never write a handler type Codex can't run, so one entry can't disable the others.
- Never write an event key Codex ignores.
- Render
command+argsas one command line, POSIX single-quoting each element, because Codex runscommandthrough<shell> -lc. This preserves the argument vector on POSIX-compatible shells; document that assumption. - Output for hooks that are already valid (supported event,
commandtype, noargs) stays byte-identical.
4. OpenCode hooks through one generated plugin for OpenCode 1 and 2
OpenCode is mid-transition. opencode-ai@1.19.4 is npm latest and keeps 1.x installs on the 1.x line. Moving to OpenCode 2 is an explicit choice, through the 2.0 offer in /update or opencode upgrade --major. OpenCode 2 ships as @opencode/cli and is now the default branch. The OpenCode README's install script, npm, and tap commands now install OpenCode 2 (#56839): opencode.ai/v2/install, @opencode/cli, and anomalyco/tap/opencode-v2. Homebrew Core's opencode formula also installs OpenCode 2 (v2.1.4). npm opencode-ai and opencode.ai/install still install only OpenCode 1. A fleet synced by Kanon will run both, and both read plugins from the same directories. So the rendered file must load on either version, and must not depend on Kanon detecting which one is installed.
When at least one hook renders for OpenCode, write one Kanon-owned file, replaced whole like other non-merged target files:
- user scope:
~/.config/opencode/plugins/kanon-hooks.js - project scope (
--project):<project>/.opencode/plugins/kanon-hooks.js
The file:
- Is loaded only through
plugins/directory auto-discovery. Kanon does not list it under thepluginorpluginsconfig key, because OpenCode 2 skips configured local file paths. - Default-exports one plain object,
{ id: "kanon-hooks", server, setup }. OpenCode 1 seesid+serverand callsserver, which returns the V1Hooksobject. OpenCode 2 decodes the default export as{ id, setup }and callssetup, which registers the same handlers throughctx. Both entrypoints share one dispatch function and one data table. - Imports only Node built-ins (
node:child_processto spawn commands). It does not import@opencode-ai/pluginor@opencode/plugin, because nothing resolves packages from a bare config directory. It does not use Bun$, because V2 doesn't provide it. - Embeds hook commands only as JSON string data, never spliced into JavaScript source.
- Is deterministic, so
kanon renderandkanon diffshow exactly what will run.
| Kanon event | OpenCode 1 (server) |
OpenCode 2 (setup) |
Exit status |
|---|---|---|---|
SessionStart |
event → session.created |
ctx.event.subscribe() → session.created |
observe-only |
UserPromptSubmit |
chat.message |
ctx.session.hook("prompt") |
observe-only |
PreToolUse |
tool.execute.before |
ctx.tool.hook("execute.before") |
exit code 2 blocks by throwing with the hook's stderr |
PostToolUse |
tool.execute.after |
ctx.tool.hook("execute.after") |
observe-only |
Stop |
event → session.idle |
ctx.event.subscribe() → session.idle |
observe-only |
Runtime contract, documented in the README:
- Commands run with
sh -c, or directly as exec form whenargsis set. - The hook receives a JSON object on stdin with these fields:
hook_event_name(the Kanon event),session_id, andcwd- for tool events,
tool_name(the OpenCode tool ID, passed through as is, so the shell tool arrives asbashon OpenCode 1 andshellon OpenCode 2) andtool_input(from V1output.argsor V2event.input)
timeoutis in seconds and kills the process when exceeded.async: trueis not awaited.- Observe-only events are listed as such, so no one expects Claude-style prompt blocking.
The maintainer's config would render Stop, PreToolUse, and UserPromptSubmit for OpenCode, with warnings for Notification and SessionEnd. It would render every hook except Notification for Codex, with a warning.
Smaller first PR, if preferred: land §1–3 with OpenCode declared hook-unsupported. An explicit targets: [opencode] hook then fails validation and implicit hooks warn, which already ends the silent no-op. §4 can then land as a follow-up that adds the OpenCode column above.
5. kanon.yaml compatibility
No schema change. Every existing config still loads. Claude output is unchanged. Codex output changes only where Codex was already ignoring or rejecting what Kanon wrote. New output appears only under the OpenCode plugin path.
Acceptance criteria
- Configs whose hooks are all supported render byte-identically for Claude and Codex.
- An implicit-target
Notificationhook is left out of Codexhooks.json, a warning names the hook,codex, and the event, and Claude still receives the hook. - An explicit target with an unsupported event, handler type, or matcher fails
kanon validate(and thereforerender/diff/apply) with an error naming the hook, the agent, and the capability. - No handler type other than
commandis ever written to Codexhooks.json. - A Codex hook with
argsrenders a single quoted command whose POSIX-shell word splitting reproduces the original argv, tested with arguments containing spaces and single quotes. - User and project scopes render
plugins/kanon-hooks.jsfor the MVP OpenCode events.targets: [opencode]with a supported event renders. No plugin file is rendered when no hook targets OpenCode. - Golden tests pin the generated plugin, including the
export default { id, server, setup }shape, Node-built-in-only imports, and JSON-escaped commands. The PR records manual runs onopencode-ai@1.19.4and@opencode/cli@2.1.4(or the then-current releases of each). Each run shows aStophook firing and aPreToolUsehook blocking with exit code2. The OpenCode 2 run also shows two parallel blocked calls, each reporting its own hook's stderr. - The README documents the per-agent hook support table and the OpenCode stdin/exit-code contract, including that the plugin targets both OpenCode 1 and 2 and that
tool_namefor the shell tool differs between them (bashversusshell). make verifypasses.
Suggested MVP
- Add per-adapter hook capability data and one
hookSupport(hook, agent)check ininternal/core. - Use it in
ValidateConfigfor explicit targets, in aHookRenderWarningshelper for implicit targets, and inhooksForAgentfiltering. - Add Codex handler shaping for
argsquoting. - Add the OpenCode plugin renderer and destinations in
opencode.go. - Print hook warnings from
validate,render,diff, andapplyin the same style as lock warnings. - Add tests for the criteria above.
Non-goals and related work
- New handler types (HTTP, prompt, agent, MCP-tool fields) stay out; #99 was closed. This proposal only stops Kanon from writing handlers an agent can't run.
- Managed hook scripts/assets stay out; #31 was closed.
- Installed-client version probing belongs to #85. The dual-entrypoint plugin avoids needing it for OpenCode 1 versus 2. This table is static per adapter; Add agent compatibility checks for version-gated native settings #85 could later use it as its hook feature source.
- Native OpenCode 2 config shapes (for example
mcp.servers) stay out. OpenCode 2 reads the V1opencode.jsonshape Kanon already writes. - Claude event allowlist or typo detection is out of scope (see closed #15); Claude stays passthrough.
- Codex trust automation is out of scope. Kanon must not write Codex hook trust hashes; each machine still reviews changed hooks in
/hooks. The existingconfig.tomlmerge already preserves unmanaged fields. - Codex managed and environment-scoped hooks (requirements-level
managed_dir, environment attachments) are out of scope. Kanon writes only the user-levelhooks.json. - Follow-ups once the MVP payload contract is proven: OpenCode matchers,
Notification/SessionEndmappings, and importing OpenCode plugins. - Pruning stale files is out of scope. Under the stateless design (README), removing every OpenCode hook leaves the last applied plugin in place, just as Codex
hooks.jsonand the Claudehookssection behave today. - Future adapters (#7, open #22) gain one place to declare hook support instead of inheriting Claude's shape by default. Add Gemini CLI render adapter with explicit agents opt-in #22's Gemini-only
geminiHookItemcheck would become that adapter's row in the table.
Activity
[-]Add rollout channels for staged Git-based Kanon source updates[/-][+]Resolve and lock MCP servers from official and private registries[/+][-]Resolve and lock MCP servers from official and private registries[/-][+][kanon-fake-strategist] Resolve and lock MCP servers from official and private registries[/+][-][kanon-fake-strategist] Resolve and lock MCP servers from official and private registries[/-][+][kanon-fake-strategist] Resolve and lock remote MCP servers from compatible registries[/+][-][kanon-fake-strategist] Resolve and lock remote MCP servers from compatible registries[/-][+][kanon-fake-strategist] Render hooks per agent capability and add OpenCode hook support[/+]