Repository navigation
fix(cli): a plugin installed for Codex loses the hooks a marketplace install keeps #698
Description
Activity
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 inapplication/use-cases/framework/strategies/tool-contracts.ts:claude ${CLAUDE_PLUGIN_ROOT} cursor ${CURSOR_PLUGIN_ROOT} copilot ${PLUGIN_ROOT} codex ${PLUGIN_ROOT}marketplace-build-strategy.tsapplies it throughrewritePluginRootToken. 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 inplugins/says${CLAUDE_PLUGIN_ROOT}.aidd plugin install <local path> --tool <x>— the translation path — does neither.PluginContentTranslatordropshooks/whenacceptsHooksis unset, and substitutes nothing when it is set.Confirmed by measurement: no tool's
rewriteContenttouches 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-startWhat 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/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.statekeys readsession_start,stop,post_tool_use, against a manifest writtenSessionStart,Stop,PostToolUse. All three events this plugin uses are covered, and its hooks live at the defaulthooks/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.jsonin 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 peopleAll 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:333declaring${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:11documents 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.
Measured: Codex expands both spellings
The open question from the plan is answered. A headless Codex session fired five
SessionStarthooks and all five completed:Source Written as the user's own ~/.codex/hooks.jsonno 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
PreToolUsehook of the user's exited non-zero and Codex reported it as Failed. An unexpanded token would have madenode "/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:
acceptsHooksis unset, and the capability defaults it tofalse. That is the whole fix for this tool.The token substitution is Cursor's problem, not Codex's. Cursor declares
${CURSOR_PLUGIN_ROOT}andacceptsHooks: 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
acceptsHooksflag 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 inhooks.stateacross three runs, whileaidd-contextandvercelload from the same cache. Its manifest and layout look equivalent; removing its.mcp.jsonchanged nothing. Its marketplacesourcepoints 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.
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/ filesBefore 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 readfound the rollout, and the report reconciled: 1 session, 1 request, 25,246 tokens, 56% cache,gpt-5.4, attributionunattributed(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.
acceptsHookswas optional and fell back tofalse, 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_hashand 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-trustmade 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_rootin the shell a skill spawns matches nothing, while the same variable expands fine inside a hook command. Both skills searched~/.claudealone 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 codexwritesmodel = "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.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 isMarketplaceBuildStrategyoutput — the route that already substituted the token before any of this. What that run does prove isacceptsHooks: 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.jsIt 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
hooksRelativePathishooks.jsonat 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.
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-startThe 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 underhooks/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.mdnow 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:changedruns 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
tsc0 · biome 0 · unit 1,893 · integration 567 · e2e 178 · plugin scripts 236.Done and awaiting review in #706, which closes this on merge.
The work is on
claude/aidd-telemetry-layer-e403uf, eleven commits, targetingnext. Gate at the time of push: 365 plugin specs, 1,931 CLI unit, 577 integration, 178 e2e,tscand 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
nexthas 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.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Projects
- StatusShow more project fieldsDone
Context
Installing
aidd-telemetryfor Codex through the CLI's own translation puts the skills and their scripts in place and silently dropshooks/. 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:
Why it happens
PluginsCapability.acceptsHooksdefaults tofalse, and only Claude Code, Copilot and Cursor declaretrue. Codex does not, sotranslateFilereturnsnullfor everything underhooks/.That default is wrong for Codex, and this machine proves it. Codex records hook state for plugin-provided hooks in its own config:
and a marketplace-installed plugin keeps its directory:
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: truethe hooks do land, byte-identical — the artefact rule holds through the real install — but the command inside them does not work:Codex expands a different variable. A plugin that works there spells it:
Installed as-is, the hook would resolve to
node /hooks/journal.jsand 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
acceptsHooksunset, 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