Skip to content

feat(cli): OpenCode joins the chain through its plugin API, not through hooks #676

Description

@blafourcade

Split out of #653, which maps the remaining tools by event name. OpenCode does not belong in that table.

Outcome

OpenCode sessions reach the run journal, through the mechanism OpenCode actually has.

Why this is not part of #653

Every other tool takes command hooks from a JSON config, so joining the chain is a naming translation the CLI already performs mechanically. OpenCode does not work that way, and cli/src/application/use-cases/framework/strategies/tool-contracts.ts already records it:

hooks: { supported: false }, // opencode has no HasHooks capability

It exposes a JavaScript plugin API instead — session.created, file.edited, tool.execute.after, session.idle among others. So there is nothing to rename: a plugin is a JS module OpenCode loads, not a command it spawns with a payload on stdin.

That makes this a new installation mode for aidd framework build, not a row in a mapping table.

Scope

  • Includes: deciding whether the framework ships an OpenCode plugin module at all, and if so where it lives — a plugin that ships JS to be loaded is a different artefact from one that ships JS to be executed.
  • Includes: mapping the three concepts the journal needs — session begins, a turn ends, a file was written — onto session.created, session.idle and file.edited.
  • Includes: whether the journal script can be reused as a library from the plugin, or has to be duplicated.
  • Excludes: the OpenTelemetry export side, which feat(framework): extend the chain to the four remaining tools #653 covers (experimental.openTelemetry in opencode.json).
  • Excludes: any other tool.

Two measured facts to carry in

OpenCode is loud. One trivial session produced 495 spans across 348 KB — everything down to file reads is instrumented. Whatever is built here must not mirror that volume into the journal, which stays one record per session.

The identifier joins on the export side, and only there. session.id appears on the ai.streamText spans, so that half is proven. What was never measured is the plugin side: no probe has run a real OpenCode plugin and compared the id it sees to the one the export carries. That measurement is the first task, and it is the same question answered for the other four tools before anything was built on top of it.

Done When

  • A probe proves the identifier a plugin sees equals the one the export carries, or proves it does not.
  • The three concepts are mapped onto real OpenCode events, or one is declared unreachable with its consequence stated.
  • An OpenCode session appears in the journal with the same ten keys as every other tool.
  • Installing on OpenCode writes nothing until the repository has opted in, exactly as elsewhere.
  • docs/ARCHITECTURE.md records the second installation mode, since it is an exception to "hooks are declared in hooks.json".

Relations

Field Value
parent #652
related #620, #653

Sources: OpenCode plugins

Activity

  1. added this to the milestone on Aug 18, 2026
  2. blafourcade commented on Aug 18, 2026

    @blafourcade
    ContributorAuthor

    Measured: OpenCode's capability is there, and the gap is entirely on our side.

    Its documentation states the agent loads a skill by calling a native tool — skill({ name: "git-release" }) — and its plugin API exposes tool.execute.before / tool.execute.after with input.tool as the canonical tool name and output.args as its arguments. So input.tool === "skill" would name a skill deterministically, with a start and an end, exactly as strong as Cursor's or Copilot's signal.

    aidd framework build already ships skills to .opencode/skills/<name>/SKILL.md (cli/src/domain/tools/ai/opencode.ts:107-114), so the skills arrive. What never arrives is anything that can observe them: tool-contracts.ts:794 records hooks: { supported: false } and the tool has no HasHooks capability.

    So this is a build-pipeline gap, not a capability gap — which narrows the issue usefully. Nothing here requires the framework to instrument itself, and nothing requires asking the model to record its own steps. It requires AIDD to grow its first OpenCode plugin surface, alongside the existing HooksCapability pattern, and that is a placement decision more than an engineering one.

    Still unmeasured, and still this issue's first task: whether the identifier a plugin sees equals the one the export carries. Everything above is documentation; no plugin was built.

  3. blafourcade commented on Aug 20, 2026

    @blafourcade
    ContributorAuthor

    Measured on opencode 1.14.20, 2026-08-20

    Probed while planning #663. Three of the assumptions in the body above can now be replaced by captures.

    A skill invocation is a tool call. Tool id skill, registered alongside read, bash, edit. The skill name is not in the tool id - it is in args.name. Its description string: "Load a specialized skill when the task at hand matches one of the skills listed in the system prompt."

    The hook pair to map onto is tool.execute.before / tool.execute.after, not the events listed above. From the binary's own call site:

    c.trigger("tool.execute.before", {tool: n_.id, sessionID: e_.sessionID, callID: e_.callID}, {args: L});
    … n_.execute(L, e_) …
    c.trigger("tool.execute.after",  {tool: n_.id, sessionID: e_.sessionID, callID: e_.callID, args: L}, ii)
    

    before fires immediately prior to execution, after immediately on resolution. Both name the tool. The full hook surface is interface Hooks in @opencode-ai/plugin@1.14.20's type declarations: event, config, tool, chat.message, chat.params, chat.headers, permission.ask, command.execute.before, tool.execute.before, shell.env, tool.execute.after, plus the experimental.* set.

    Config-declared hooks really are gone in 1.14.20, and the SDK types lie about it. experimental.hook.file_edited / session_completed survive in the bundled SDK type declarations, but both strings are absent from the shipped binary, and opencode debug config silently drops the key:

    OPENCODE_CONFIG_CONTENT='{"experimental":{"hook":{"session_completed":[…]},"openTelemetry":true}}' opencode debug config
    → resolved output keeps "openTelemetry": true, drops "hook" entirely
    

    So hooks: { supported: false } is right about hooks.json and wrong as a statement about the tool - OpenCode ships a hook-shaped plugin API. Worth correcting that comment while here.

    What this means for the step boundary #663 defines

    Skill load completion is observable: tool.execute.after, ToolStateCompleted.time.end, title: "Loaded skill: <name>". Skill workflow completion is not, by any surface in 1.14.20 - the same limit as the other four tools, and #663's half-open interval rule covers it unchanged. So once this ticket puts OpenCode sessions in the journal, the step boundary is tool.execute.before with tool === "skill" and args.name, and nothing further is needed.

    Open question this ticket must answer

    Whether the plugin can reach the run journal at all. A plugin runs inside OpenCode's process, not as a spawned command, so the journal's zero-dependency CommonJS hook cannot be invoked the way hooks.json invokes it elsewhere. That is the "reused as a library, or duplicated" question in the scope above, and the capture does not settle it.

    Session identity, for the export side

    session.id on spans is confirmed, with a correction on the field name: the AI SDK telemetry metadata uses sessionId (experimental_telemetry: {…, metadata: {…, sessionId: c.sessionID}}), while a tracer proxy stamps the attribute session.id on every span it starts. Both are gated on experimental.openTelemetry. No exported span was captured, so telemetryExport: { kind: "unmeasured" } stays accurate.


    Closing #682 as a duplicate of this: it was filed without checking the backlog first, and this ticket is broader and came first.

  4. moved this from Ideation to Todo in AIDD Roadmapon Aug 20, 2026
  5. blafourcade commented on Aug 22, 2026

    @blafourcade
    ContributorAuthor

    An OpenCode session does see its own id, and the journal now writes

    OpenCode auto-loads JS/TS files from {plugin,plugins}/*.{ts,js} — established from the compiled binary's own strings and confirmed on every live probe. Its Hooks.event callback carries the session's own id directly, and that id is the one the existing reader already keys its records on.

    A plugin was written that spawns journal.js as a child process — exactly what every other host's hook already does — naming itself {tool: "opencode", …}, since OpenCode has no vendor payload to reverse-engineer.

    The join is proven, not asserted: telemetry-report.js read with no --session discovered a session created through OpenCode's own HTTP API. That is what journalAttributable promises — a sweep reaching a session nobody named by hand — so the flag flipped to true on that evidence.

    Two loader constraints, neither documented, both found by losing sessions to them

    1. Only a genuine ESM export runs. All three budgeted opencode run sessions used CommonJS shapes — a bare function, then {server: fn} matching the shipped .d.ts. Each was discovered by the loader, logged, and never executed. No error, no warning. An ESM file worked on the very next attempt.
    2. The loader cannot see a local CommonJS file's exports at all. await import("./lib/record.js") resolves to an empty namespace, and so does a trivial one-line CJS file. A genuine ESM sibling imports fine.

    Both are the shape this whole layer exists to catch: a thing that looks installed, runs nothing, and says nothing.

    Volume, against this ticket's own warning

    One session.created / session.idle event per turn. Nowhere near the 495 spans across 348 KB that #653 records for a trivial session — that figure is OTEL span-level instrumentation, and this hooks only the plugin event bus.

    What is not done, and it matters

    The install route still skips it. aidd plugin add and aidd framework build emit "hooks skipped for opencode" for any plugin shipping hooks/, and rightly so — a declarative hooks.json means nothing to this runtime. Every capture here used a hand-placed file.

    So the mechanism is proven and the delivery is not. A person installing through the CLI today gets the declaration and not the plugin. The remaining work is to deliver the ESM module on the OpenCode route the way hooks/hooks.json is delivered on the others.

    The turn-end trigger is inferred, not captured. session.idle is what the plugin subscribes to for a turn boundary; it was reasoned from the event names, not observed closing a turn in a captured run.

    Also caught

    Adding telemetryJournalHost: "opencode" to opencode.ts was not optional — the conformance test that pins the CLI's derivation against the plugin's TOOLS declaration failed until it was there. That test was written two phases ago after the same two copies drifted on Cursor, and this is the second time it has earned itself.

  6. moved this from Todo to In review in AIDD Roadmapon Aug 22, 2026
  7. 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.

  8. blafourcade commented on Sep 2, 2026

    @blafourcade
    ContributorAuthor

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

  9. moved this from In review to Done in AIDD Roadmapon Sep 2, 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

    Fields

    Priority

    High

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions