Skip to content

fix(cli): a plugin installed for Codex loses the hooks a marketplace install keeps #698

Description

@blafourcade

Context

Installing aidd-telemetry for Codex through the CLI's own translation puts the skills and their scripts in place and silently drops hooks/. The run journal is a hook, so a Codex session records nothing — not because of a payload shape, but because the hook is never installed.

Measured by installing it the way the framework does:

aidd plugin install <path> --tool codex --scope project --yes
→ .codex/plugins/aidd-telemetry/skills/…       18 files
→ .codex/plugins/aidd-telemetry/hooks/…        absent

Why it happens

PluginsCapability.acceptsHooks defaults to false, and only Claude Code, Copilot and Cursor declare true. Codex does not, so translateFile returns null for everything under hooks/.

That default is wrong for Codex, and this machine proves it. Codex records hook state for plugin-provided hooks in its own config:

[hooks.state."aidd-context@aidd-framework:hooks/hooks.json:session_start:0:0"]
[hooks.state."aidd-refine@aidd-framework:hooks/hooks.json:user_prompt_submit:0:0"]

and a marketplace-installed plugin keeps its directory:

~/.codex/plugins/cache/aidd-framework/aidd-context/2.6.2/hooks/

So the two install routes disagree about what a plugin is. The marketplace path delivers hooks; the translation path drops them.

The second half, which flipping the flag does not fix

With acceptsHooks: true the hooks do land, byte-identical — the artefact rule holds through the real install — but the command inside them does not work:

"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/journal.js session-start"

Codex expands a different variable. A plugin that works there spells it:

"command": "node ${PLUGIN_ROOT}/hooks/update_memory.js"

Installed as-is, the hook would resolve to node /hooks/journal.js and fail silently on every event — worse than not installing it, since a broken hook looks like an installed one. The flag was flipped, measured, and reverted for exactly that reason.

The event names need no translation: Codex takes the same PascalCase keys.

Expected

A plugin installed for a tool that runs hooks arrives with hooks that run. The plugin-root variable is that tool's own, declared beside its other plugin facts, never one tool's spelling shipped to another.

Acceptance

  • Codex declares that it accepts hooks, and the declaration is checked against what the tool actually does rather than assumed.
  • The plugin-root variable is declared per tool and substituted on translation.
  • A hook installed for any tool resolves to a path that exists — asserted, since a hook that resolves to nothing produces no error and no line.
  • The two install routes agree: what a marketplace install delivers and what a local install delivers hold the same files.
  • OpenCode is looked at too. It also leaves acceptsHooks unset, and its skip is already recorded with a reason — this ticket should confirm that reason still holds rather than inherit it.

What it unblocks

The run journal on Codex, and with it every step attribution for that tool. Codex's records already carry a moment and join to a journal interval correctly; there has simply never been a journal for them to join to.

Relations

Field Value
parent #631
related #681, #676, #653

Activity

  1. blafourcade commented on Aug 21, 2026

    @blafourcade
    ContributorAuthor

    Sharper, after reading further

    The knowledge this ticket needs already exists in the repository. Two install paths use it differently, and that is the whole bug.

    aidd framework build — the release-archive path — substitutes a per-tool token declared in application/use-cases/framework/strategies/tool-contracts.ts:

    claude   ${CLAUDE_PLUGIN_ROOT}
    cursor   ${CURSOR_PLUGIN_ROOT}
    copilot  ${PLUGIN_ROOT}
    codex    ${PLUGIN_ROOT}
    

    marketplace-build-strategy.ts applies it through rewritePluginRootToken. That is why the plugins installed on this machine carry the right variable — ${PLUGIN_ROOT} in Copilot's tree, ${CURSOR_PLUGIN_ROOT} in Cursor's — while every source file in plugins/ says ${CLAUDE_PLUGIN_ROOT}.

    aidd plugin install <local path> --tool <x> — the translation path — does neither. PluginContentTranslator drops hooks/ when acceptsHooks is unset, and substitutes nothing when it is set.

    Confirmed by measurement: no tool's rewriteContent touches the token, so nothing on the translation path ever could.

    claude   -> node ${CLAUDE_PLUGIN_ROOT}/hooks/journal.js session-start
    codex    -> node ${CLAUDE_PLUGIN_ROOT}/hooks/journal.js session-start
    copilot  -> node ${CLAUDE_PLUGIN_ROOT}/hooks/journal.js session-start
    cursor   -> node ${CLAUDE_PLUGIN_ROOT}/hooks/journal.js session-start
    opencode -> node ${CLAUDE_PLUGIN_ROOT}/hooks/journal.js session-start
    

    What that changes about the fix

    It is not new knowledge to acquire. It is one declaration read from a second place, plus a flag Codex should already have carried.

    And it means Copilot and Cursor have the same latent bug: both declare acceptsHooks: true, so a locally installed plugin gives them hooks whose command names Claude Code's variable. Nobody noticed because nobody installs a hook-carrying plugin that way — until this one.

    Codex's ${PLUGIN_ROOT} is already declared and already correct. Nothing about the token needs measuring; it needs reaching.

    Plan: aidd_docs/tasks/2026_08/2026_08_21_plugin-hooks-install/

  2. blafourcade commented on Aug 21, 2026

    @blafourcade
    ContributorAuthor

    Plan

    aidd_docs/tasks/2026_08/2026_08_21_plugin-hooks-install/ — spec, plan, three phases.

    # Phase
    1 One place says which variable a tool expands
    2 A tool that runs hooks receives them
    3 An installed hook is proven to resolve

    Two things measured while planning, both of which changed it

    Codex normalizes event names. Its hooks.state keys read session_start, stop, post_tool_use, against a manifest written SessionStart, Stop, PostToolUse. All three events this plugin uses are covered, and its hooks live at the default hooks/hooks.json. No event translation is needed, which was the one thing that could have made this ticket much larger.

    Codex's plugin-root token is not settled, and the declaration cannot settle it. Every hooks.json in Codex's own plugin cache:

    ${PLUGIN_ROOT}          aidd-context, aidd-test        built by this framework
    ${CLAUDE_PLUGIN_ROOT}   vercel, impeccable, ralph-loop shipped by other people
    

    All five are registered in hooks.state. A hook whose command resolves to nothing still registers, so registration proves nothing about which spelling expands. tool-contracts.ts:333 declaring ${PLUGIN_ROOT} for Codex is therefore an assumption that has never been tested — and getting it wrong installs a hook that runs on every event and silently does nothing, which is the exact failure this ticket exists to remove.

    Phase 1 settles it by running a hook under Codex whose command carries both spellings and records what each expanded to.

    Three corrections to the first draft

    • Comparing the two install routes cannot be a comparison of paths and contents. They write different layouts by design — one a bundle, the other a tool's own directory — so identity could never pass, and the only exit would be loosening the check until it caught nothing. The invariant that actually holds is the component set (hooks arrive from both routes exactly when the tool runs hooks) and the hook command itself, which both must produce identically.
    • The skills name the root bare, as $CLAUDE_PLUGIN_ROOT, in a shell the skill spawns; the rewrite matches only ${CLAUDE_PLUGIN_ROOT}. Whether a tool exports that variable to a skill's shell is a different question from whether it expands it in a hook command, and only the second has been measured. Phase 2 measures before rewriting, rather than pointing the prose at a variable that may be empty at skill time.
    • plugin-root-token-rewrite.ts:11 documents Copilot's token as ${COPILOT_PLUGIN_ROOT}. The declaration says ${PLUGIN_ROOT}, and the declaration is what runs.

    Out of scope, stated so it is not mistaken for done

    Making a hook installable is not making it fire. Whether Codex's payload is then recognised by the journal is a separate question, already answered for its session detection and not for its delivery.

  3. blafourcade commented on Aug 21, 2026

    @blafourcade
    ContributorAuthor

    Measured: Codex expands both spellings

    The open question from the plan is answered. A headless Codex session fired five SessionStart hooks and all five completed:

    Source Written as
    the user's own ~/.codex/hooks.json no plugin root
    aidd-context@aidd-framework ${PLUGIN_ROOT}
    vercel@claude-plugins-official, three of them ${CLAUDE_PLUGIN_ROOT}

    Both referenced scripts exist on disk, and the same run shows what a broken hook looks like: a PreToolUse hook of the user's exited non-zero and Codex reported it as Failed. An unexpanded token would have made node "/hooks/session-start-seen-skills.mjs" fail exactly that way. It did not.

    So Codex's declared ${PLUGIN_ROOT} is correct, and the source spelling would have worked too.

    What that changes

    Codex needs no token work at all. Its hooks go missing for one reason: acceptsHooks is unset, and the capability defaults it to false. That is the whole fix for this tool.

    The token substitution is Cursor's problem, not Codex's. Cursor declares ${CURSOR_PLUGIN_ROOT} and acceptsHooks: true, so it is already receiving hooks that name another tool's variable — and unlike Codex, nothing suggests it expands the source spelling. Its token is now the one value in the table still taken on faith. Phase 1 runs the same probe against it.

    Keeping the two apart matters: had this shipped as one change, a Codex install would have been fixed by the acceptsHooks flag while the substitution silently did nothing, and nobody would have learned which half worked.

    Note on the probe

    The intended vehicle was aidd-test@aidd-framework, a scratch plugin already in Codex's cache. It never loads — its hooks produce no entry in hooks.state across three runs, while aidd-context and vercel load from the same cache. Its manifest and layout look equivalent; removing its .mcp.json changed nothing. Its marketplace source points at a deleted temp directory, which is the most likely cause but is unconfirmed. Not chased further, since the plugins that do load answered the question. Worth knowing if anyone else reaches for that plugin as a test fixture.

    Everything the probe touched has been restored.

  4. blafourcade commented on Aug 21, 2026

    @blafourcade
    ContributorAuthor

    Phases 1 and 2 land, and the journal now runs on Codex

    Verified end to end on a real Codex session, not only in tests. From an empty directory:

    aidd setup --ai codex --source local --path <repo> --plugins none --yes
    aidd plugin install aidd-telemetry --tool codex --yes
    codex exec "Run: ls -a . Then say DONE."
    

    What landed:

    ~/.codex/plugins/cache/aidd-framework/aidd-telemetry/0.1.0/hooks/hooks.json
      "command": "node ${PLUGIN_ROOT}/hooks/journal.js session-start"
    ~/.codex/plugins/cache/.../hooks/journal.js        + its five lib/ files
    

    Before this change the same install delivered eighteen files and no hooks/ at all.

    The session's own journal, written by the hook:

    {"type":"session_start","at":"2026-08-21T20:23:52Z","schema_version":2,
     "run_id":"01M0JZX1QA36NS223QXMSZZQTD","tool":"codex",
     "vendor_id":"01a025fe-7f49-7441-9239-6e1dd5d0e553","vendor_field":"conversation.id"}
    {"type":"turn_end","at":"2026-08-21T20:23:57Z"}

    And the whole chain on top of it — telemetry-report.js read found the rollout, and the report reconciled: 1 session, 1 request, 25,246 tokens, 56% cache, gpt-5.4, attribution unattributed (no skill ran in that session, which is the honest answer rather than a zero).

    What changed

    The plugin-root variable moved to the tool. plugins.pluginRootToken, picked from a shared vocabulary of three constants. The build route reads it instead of holding its own copy; the translation route substitutes it where it previously did nothing.

    Hook support is now stated, never defaulted. acceptsHooks was optional and fell back to false, which is how Codex lost its hooks without anyone writing a line of code to that effect. It is required now, and a tool that runs none must say why in the same declaration — the type will not let one be given without the other. Removing the fallback broke four test files, every one of them a fixture that had never said anything on the subject. That is the point of removing it.

    A script beside a hook is still carried byte for byte. Only the hook manifest and other prose are translated.

    Three things measured that were not on the plan

    A freshly installed Codex hook does not run. Codex holds a per-hook trusted_hash and skips anything it has not seen approved — silently, with no line saying hooks were skipped. Four consecutive sessions ran clean and wrote no journal before --dangerously-bypass-hook-trust made the difference visible. For a person this resolves at the first interactive approval; for anything headless it means the journal is simply absent, and nothing says so. Worth its own ticket.

    Codex sets no plugin-root variable for a skill. env | grep -i plugin_root in the shell a skill spawns matches nothing, while the same variable expands fine inside a hook command. Both skills searched ~/.claude alone to find their own script, so on Codex each would have reported its script missing while sitting installed beside it. They now search each tool's plugin directory, installed plugins before the working directory — the working directory first would have found the build cache instead of the installed copy, which is the same file today and would not be after any change.

    Cursor now has three answers to one question. Its converter rewrites the root to ./, the build route writes ${CURSOR_PLUGIN_ROOT}, and the source says ${CLAUDE_PLUGIN_ROOT}. Pinned in a test as the divergence it is rather than reconciled: Cursor is the one tool whose hooks could not be observed running, so neither answer has been checked against it. Phase 3 is where the two routes get held to each other.

    Unrelated, one line

    aidd setup --ai codex writes model = "gpt-5" into the project's .codex/config.toml. A ChatGPT-account Codex rejects it: The 'gpt-5' model is not supported when using Codex with a ChatGPT account. Every session in a freshly set-up project fails until it is changed by hand.

  5. blafourcade commented on Aug 21, 2026

    @blafourcade
    ContributorAuthor

    Correction, and one real defect the correction turned up

    Two claims in the previous comment were wider than what was measured. Both come from the same mistake: I proved the Codex end-to-end and then described the whole change as proven by it.

    The Codex run went through the build route, not the one this ticket fixed. The files landed in ~/.codex/plugins/cache/…, which is MarketplaceBuildStrategy output — the route that already substituted the token before any of this. What that run does prove is acceptsHooks: true: the hooks arrive, fire, and write the journal. It proves nothing about the translation route.

    So I measured the translation route directly, by disabling the substitution and reinstalling. It is reached by aidd plugin install <local path> --tool cursor, and on that path it is load-bearing:

    source                            ${CLAUDE_PLUGIN_ROOT}/bin/server.js
    installed, substitution off       ${CLAUDE_PLUGIN_ROOT}/bin/server.js   <- Cursor expands nothing
    installed, substitution on        ${CURSOR_PLUGIN_ROOT}/bin/server.js
    

    It is not reached when the plugin comes from a marketplace, because the distribution the translator reads is the already-built per-tool bundle, with the token substituted upstream. That is the whole reason this went unnoticed: the common path was always right.

    The other trimmed claim: I wrote that searching the working directory first "would have found the build cache instead of the installed copy, which is the same file today and would not be after any change." I verified the search order changes which path wins. I did not establish that the two ever diverge. The ordering is still right — an installed plugin is what a skill should run — but that is the reason, not the one I gave.

    The defect

    Installing from a local path for Cursor writes a hook command that points at nothing:

    ~/.cursor/plugins/local/aidd-telemetry/
      hooks.json      "command": "node ./hooks/journal.js session-start"
      journal.js      <- at the plugin root, not under hooks/
      lib/
    

    Cursor's hooksRelativePath is hooks.json at the plugin root, so the scripts land beside it while the converter still writes ./hooks/…. Every hook in that install resolves to a missing file. It installs clean, it reports success, and the journal is simply never written — the same silent shape as the bug this ticket started from, one directory over.

    Found by installing rather than by reading, which is the argument for phase 3: a test that checks a hook command resolves to a file that exists.

  6. blafourcade commented on Aug 21, 2026

    @blafourcade
    ContributorAuthor

    Phase 3, and the defect it was written to find

    The test asks the only question that matters about an installed hook: does the file its command names exist in the same install? It failed on the first run, on Cursor:

    delivered:   hooks.json, /journal.js, /lib/record.js
    command:     node ./hooks/journal.js session-start
    

    The scripts were landing at the plugin root because Cursor keeps its manifest there, while the command still named hooks/. Every hook in that install pointed at nothing. Fixed by keeping the scripts in their own directory when the manifest sits at the root, which leaves the three tools whose manifest lives under hooks/ untouched. Confirmed on a real install:

    ~/.cursor/plugins/local/aidd-telemetry/
      hooks.json        "command": "node ./hooks/journal.js session-start"
      hooks/journal.js
      hooks/lib/
    

    Two more tests hold the routes together: a hook points at the same file whichever route delivered it, and hooks arrive exactly when the tool runs them.

    The tests have teeth, which took a second pass

    The resolve test was first written so that an install producing no readable manifest would iterate zero commands and pass. Green while asserting nothing is the exact failure shape this ticket is about, so each tool now asserts it found a command before checking it, and that guard was verified by stubbing the manifest empty and watching three tests fail.

    Documented

    docs/ARCHITECTURE.md now carries a per-tool table beside the bundled-hooks list: whether a tool runs them, which variable it resolves, and for the two that were never observed running a hook, that this is a declaration rather than a measurement. OpenCode appears there with its reason, so the absence reads as known.

    A faster loop, without skipping what can break

    Running everything after each edit costs over two minutes, most of it in suites the edit cannot reach. pnpm test:changed runs only what a change can break: vitest resolves the CLI's import graph, and the plugin specs — which reach their subject by path, not by import — are selected by the paths their own text names, a named directory counting for everything below it. A spec that names no source of ours runs regardless, because not finding a reference is not evidence that nothing broke.

    A CLI-only change went from 2m17s to 25s, running 1,218 tests instead of 2,638.

    Not met, stated rather than glossed

    Phase 1 asked that every declared token be one a running hook resolved. Codex's was. Cursor's was not — two headless probes fired no plugin hook at all — so its value stays what the build route has been shipping, marked unmeasured at the declaration site and beside the plan's done marker.

    Gate

    tsc 0 · biome 0 · unit 1,893 · integration 567 · e2e 178 · plugin scripts 236.

  7. moved this from Ideation to In review in AIDD Roadmapon Aug 22, 2026
  8. blafourcade commented on Aug 22, 2026

    @blafourcade
    ContributorAuthor

    Done and awaiting review in #706, which closes this on merge.

    The work is on claude/aidd-telemetry-layer-e403uf, eleven commits, targeting next. Gate at the time of push: 365 plugin specs, 1,931 CLI unit, 577 integration, 178 e2e, tsc and biome clean, no broken markdown links.

    Read the eleven commits rather than the pull request's file count — the branch carries its own copy of the CLI migration that next has since received, so the diff counts it twice. The range and the real figures are in the first comment on #706: 142 files, +10,518 −418.

    Every claim about a tool in this work rests on a session that was actually run. The negative results are kept in full in aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/measurements.md, including the two probes that disagreed about OpenCode and the measurement that reconciled them.

  9. blafourcade commented on Sep 2, 2026

    @blafourcade
    ContributorAuthor

    Delivered by #706, squash-merged into next as 627408f.

  10. moved this from In review to Done in AIDD Roadmapon Sep 2, 2026
  11. added theissue type on Sep 14, 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

    Labels

    No labels
    No labels

    Type

    Fields

    Priority

    High

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions