Repository navigation
feat(cli): OpenCode joins the chain through its plugin API, not through hooks #676
Description
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 capabilityIt 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.idleandfile.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.openTelemetryinopencode.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.mdrecords 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
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Projects
- StatusShow more project fieldsDone
Activity
blafourcade commentedon Aug 18, 2026
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 exposestool.execute.before/tool.execute.afterwithinput.toolas the canonical tool name andoutput.argsas its arguments. Soinput.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 buildalready 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:794recordshooks: { supported: false }and the tool has noHasHookscapability.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
HooksCapabilitypattern, 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.
blafourcade commentedon Aug 20, 2026
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 alongsideread,bash,edit. The skill name is not in the tool id - it is inargs.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:beforefires immediately prior to execution,afterimmediately on resolution. Both name the tool. The full hook surface isinterface Hooksin@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 theexperimental.*set.Config-declared hooks really are gone in 1.14.20, and the SDK types lie about it.
experimental.hook.file_edited/session_completedsurvive in the bundled SDK type declarations, but both strings are absent from the shipped binary, andopencode debug configsilently drops the key:So
hooks: { supported: false }is right abouthooks.jsonand 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 istool.execute.beforewithtool === "skill"andargs.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.jsoninvokes 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.idon spans is confirmed, with a correction on the field name: the AI SDK telemetry metadata usessessionId(experimental_telemetry: {…, metadata: {…, sessionId: c.sessionID}}), while a tracer proxy stamps the attributesession.idon every span it starts. Both are gated onexperimental.openTelemetry. No exported span was captured, sotelemetryExport: { 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.
blafourcade commentedon Aug 22, 2026
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. ItsHooks.eventcallback 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.jsas 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 readwith no--sessiondiscovered a session created through OpenCode's own HTTP API. That is whatjournalAttributablepromises — a sweep reaching a session nobody named by hand — so the flag flipped totrueon that evidence.Two loader constraints, neither documented, both found by losing sessions to them
opencode runsessions 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.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.idleevent 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 addandaidd framework buildemit "hooks skipped for opencode" for any plugin shippinghooks/, and rightly so — a declarativehooks.jsonmeans 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.jsonis delivered on the others.The turn-end trigger is inferred, not captured.
session.idleis 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"toopencode.tswas not optional — the conformance test that pins the CLI's derivation against the plugin'sTOOLSdeclaration 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.blafourcade commentedon Aug 22, 2026
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.docs(framework): three documents catch up with the code they describe
blafourcade commentedon Sep 2, 2026
Delivered by #706, squash-merged into
nextas 627408f.