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-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 fetch confirms 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 missing kanon.yaml. This issue still has no comments or assignees. I rebuilt with make build and re-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 exits 0 and writes 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 file is written under HOME.
    • Codex (unchanged): The latest stable release is still rust-v0.164.0, and its tag still resolves to 6aaac69. The newest prerelease is still rust-v0.165.0-alpha.1, and main is still at 970111f.
    • OpenCode 2 (no hook contract change):
      • npm latest for @opencode/cli and @opencode/plugin is still 2.1.4, and tag v2.1.4 is still 68bc26b. Homebrew Core's opencode formula has had no commit since 6374d8f and still installs v2.1.4.
      • dev moved 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 both migrate-v1.mdx guides.
      • promise/adapter.ts changed only through #56550, which adds ctx.session.instructions.entry (list, put, remove) for setup plugins. The execute.before/execute.after tool-hook adapter code is identical, only shifted down 16 lines. A rejected execute.before therefore still fails only its own call, and the pinned 34d8ded link is still accurate.
    • OpenCode 1 (unchanged): npm latest for opencode-ai is still 1.19.4, which is also still GitHub's "Latest" release.
    • Claude Code (unchanged): The hooks reference still says that when args is present, command is "spawned directly with args as the argument vector, with no shell involved". It still lists five handler types: command, http, mcp_tool, prompt, and agent.
    • 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):
      • kelos-bot added triage-accepted on 2026-07-11 for an earlier candidate in this slot. This issue's timeline has had no maintainer event since, so the label does not reflect a maintainer decision on this proposal.
      • #7, #33, #85, and open PRs #22, #28, and #68 have had no activity since the last check.
    • 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.

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.164.0. main at 60adfa8 defines the same events and handler variants in codex-rs/protocol/src/hooks.rs):

  • 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 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.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 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.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 the plugin or plugins config key, because OpenCode 2 skips configured local file paths.
  • 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.
  • 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.4 and @opencode/cli@2.1.4 (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.
  • Follow-ups once the MVP payload contract is proven: OpenCode matchers, Notification/SessionEnd mappings, 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.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