Skip to content

[kanon-fake-strategist] Render hooks per agent capability and add OpenCode hook support #100

Open

Description

@kelos-bot

🤖 Kelos Strategist Agent @gjkim42

Latest verdict

  • Status: VALID
  • Checked at: 2026-11-01T12:03:29Z
  • Evidence: The gap still reproduces on the unchanged default branch. Since the 2026-11-01T00:03:00Z check, the only change to an external contract is nine new commits on OpenCode dev, and none touch a cited file. The proposal below needs no edits.
    • Kanon: git fetch confirms the default branch is still d588121, so every code link below still points at the cited lines. This issue still has no comments or assignees. I rebuilt with make build and ran the reproduction below in an empty HOME, adding a targets: [codex] hook with type: http:
      • kanon validate prints kanon.yaml is valid. with no warning.
      • kanon render --agent codex and kanon apply --agent codex --yes exit 0. They write the Notification event, the args array, and a bare "type": "http" handler into ~/.codex/hooks.json.
      • kanon render --agent opencode prints No target files. in user scope and with --project <dir>, even for the hook with targets: [opencode]. kanon apply --agent opencode --yes prints No changes., and no ~/.config/opencode is created.
      • Config still manages only instructions, skills, MCP servers (including MCP tool approval policies), and hooks. OpenCode renders the first three, so hooks are still the only managed type that doesn't reach every shipped agent.
    • OpenCode (no new release; nothing cited changed on dev):
      • No release since v2.2.5 (2026-10-30T23:17:23Z), and GitHub's Latest badge still points at v1.19.5. npm latest is still 2.2.5 for @opencode/cli and @opencode/plugin, and still 1.19.5 for opencode-ai. Homebrew Core's opencode formula is still 2.2.5.
      • dev moved from 84f6605 to 69269e4. The compare lists 9 commits and 277 files. That is under the compare API's 300-file cap, so the list is complete. None of the cited files changed.
        • The only plugin-adjacent changes are per-model session warming (#60025) and in-memory skill:// URIs (#60921).
        • #60921 only exports two path helpers from skill/discovery.ts. It doesn't move the skill directories Kanon renders into.
        • At 69269e4, the plugin execute.before event still carries tool and input, and only execute.before may fail.
    • Codex and Claude Code (unchanged):
      • rust-v0.166.0 is still the latest stable Codex release, and its annotated tag still dereferences to 6ff1e1c. No commit on main since 2026-10-29 touches codex-rs/hooks, protocol/src/hooks.rs, config/src/hook_config.rs, or core/src/hook_runtime.rs.
      • The Claude Code hooks reference still runs args in exec form "with no shell involved". It still lists the command, http, mcp_tool, prompt, and agent handler types, and still lists Notification and SessionEnd as events.
    • Maintainer config: gjkim42/kanon-repo is still at 7517080. Its kanon.yaml is still blob 538e558, with the same five untargeted hooks at lines 12–52.
    • Overlap and alternatives:

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

  1. It is the maintainer's own workflow. gjkim42/kanon-repo syncs five untargeted tmux-status hooks (Stop, Notification, PreToolUse, SessionEnd, UserPromptSubmit). Today Codex ignores the Notification one, and OpenCode gets none of them.
  2. Silent safety degradation. A PreToolUse guard written with args (Claude's exec form) runs in Codex without its arguments, because Codex drops the unknown args key.
  3. One bad entry disables every Codex hook. Kanon passes type through 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.
  4. 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.
  5. 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 done

kanon validate prints kanon.yaml is valid. and kanon render exits 0, but:

  • ~/.codex/hooks.json contains Notification, which is not a Codex event, so Codex ignores it.
  • ~/.codex/hooks.json contains "args": ["--deny", "rm -rf"]. Codex ignores it and runs /usr/local/bin/guard with no arguments.
  • Nothing is rendered for OpenCode, including the hook explicitly targeted at opencode.
  • A separate type: http hook (no URL) also validates and is written to hooks.json. Per Codex's parser, that file then fails to parse and every Codex hook in it is dropped.

Current product gap

External contracts

Codex (pinned at the latest stable release, rust-v0.166.0. The hook types live in codex-rs/protocol/src/hooks.rs, and codex-rs/config/src/hook_config.rs re-exports them):

  • hooks.json accepts exactly PreModelRequest, PostModelRequest, PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, SessionEnd, UserPromptSubmit, SubagentStart, SubagentStop, Stop, and Interrupt (HookEventsToml). PreModelRequest and PostModelRequest are observation-only (#55144). Unknown event keys are ignored because only the top-level HooksFile uses deny_unknown_fields.
  • Handler variants are command (fields command, commandWindows, timeout, async, statusMessage, additionalContextLimit) and mcp_tool. prompt and agent are parsed but skipped. Any other type is 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.5):

  • 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 id and a server function is loaded as a module plugin. The loader returns that object without checking for other keys, and path plugins must export id.
  • server returns the Hooks object: event (including session.created and session.idle), chat.message, tool.execute.before, and tool.execute.after. Throwing from tool.execute.before blocks the tool call.
  • The shell tool's ID stays bash in 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):

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 (targets names 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, or hook "audit" targets codex but type "http" is not supported for codex. render, diff, and apply already run validation, so they fail the same way.
  • Implicit target (targets omitted or all): the hook is left out for that agent. validate, render, diff, and apply print 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 stating targets.

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 + args as one command line, POSIX single-quoting each element, because Codex runs command through <shell> -lc. This preserves the argument vector on POSIX-compatible shells; document that assumption.
  • Output for hooks that are already valid (supported event, command type, no args) stays byte-identical.

4. OpenCode hooks through one generated plugin for OpenCode 1 and 2

OpenCode is mid-transition. opencode-ai@1.19.5 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.2.5). 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 the plugin or plugins config key, because OpenCode 2 skips configured local file paths.
  • Is a single file, not a plugin directory. OpenCode 1 auto-loads only *.{ts,js} files directly under plugins/. OpenCode 2 auto-loads both files and directories there, although its plugin guide shows the directory layout and its loader calls single files legacy.
  • Default-exports one plain object, { id: "kanon-hooks", server, setup }. OpenCode 1 sees id + server and calls server, which returns the V1 Hooks object. OpenCode 2 decodes the default export as { id, setup } and calls setup, which registers the same handlers through ctx. Both entrypoints share one dispatch function and one data table.
  • Calls ctx.event.subscribe() with no arguments in setup and filters for session.created and session.idle itself. The { types } filter exists only from @opencode/cli 2.2.0 (#58864), and subscribe() with no arguments returns every public event both before and after that change.
  • Imports only Node built-ins (node:child_process to spawn commands). It does not import @opencode-ai/plugin or @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 render and kanon diff show 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 when args is set.
  • The hook receives a JSON object on stdin with these fields:
    • hook_event_name (the Kanon event), session_id, and cwd
    • for tool events, tool_name (the OpenCode tool ID, passed through as is, so the shell tool arrives as bash on OpenCode 1 and shell on OpenCode 2) and tool_input (from V1 output.args or V2 event.input)
  • timeout is in seconds and kills the process when exceeded. async: true is 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

  1. Configs whose hooks are all supported render byte-identically for Claude and Codex.
  2. An implicit-target Notification hook is left out of Codex hooks.json, a warning names the hook, codex, and the event, and Claude still receives the hook.
  3. An explicit target with an unsupported event, handler type, or matcher fails kanon validate (and therefore render/diff/apply) with an error naming the hook, the agent, and the capability.
  4. No handler type other than command is ever written to Codex hooks.json.
  5. A Codex hook with args renders a single quoted command whose POSIX-shell word splitting reproduces the original argv, tested with arguments containing spaces and single quotes.
  6. User and project scopes render plugins/kanon-hooks.js for the MVP OpenCode events. targets: [opencode] with a supported event renders. No plugin file is rendered when no hook targets OpenCode.
  7. 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 on opencode-ai@1.19.5 and @opencode/cli@2.2.5 (or the then-current releases of each). Each run shows a Stop hook firing and a PreToolUse hook blocking with exit code 2. The OpenCode 2 run also shows two parallel blocked calls, each reporting its own hook's stderr.
  8. 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_name for the shell tool differs between them (bash versus shell).
  9. make verify passes.

Suggested MVP

  1. Add per-adapter hook capability data and one hookSupport(hook, agent) check in internal/core.
  2. Use it in ValidateConfig for explicit targets, in a HookRenderWarnings helper for implicit targets, and in hooksForAgent filtering.
  3. Add Codex handler shaping for args quoting.
  4. Add the OpenCode plugin renderer and destinations in opencode.go.
  5. Print hook warnings from validate, render, diff, and apply in the same style as lock warnings.
  6. 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 V1 opencode.json shape 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 existing config.toml merge 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-level hooks.json.
  • OpenCode 2 managed plugin policy is out of scope. A managed plugin.use deny (from v2.2.3; before that, integration.use with a plugin: resource) blocks the generated plugin. Kanon does not read or write managed policy, so the README should say that such a policy disables OpenCode hooks.
  • Follow-ups once the MVP payload contract is proven: OpenCode matchers, Notification/SessionEnd mappings, importing OpenCode plugins, and a Stop mapping that can keep an OpenCode 2 session running through the experimental experimental.idle session hook (added in 2.2.1 by #59437).
  • 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.json and the Claude hooks section 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 geminiHookItem check would become that adapter's row in the table.

Activity

  1. changed the title [-]Add rollout channels for staged Git-based Kanon source updates[/-] [+]Resolve and lock MCP servers from official and private registries[/+] on Jul 11, 2026
  2. changed the title [-]Resolve and lock MCP servers from official and private registries[/-] [+][kanon-fake-strategist] Resolve and lock MCP servers from official and private registries[/+] on Aug 8, 2026
  3. changed the title [-][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[/+] on Aug 16, 2026
  4. changed the title [-][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[/+] on Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions