feat: add OpenCode plugin as 3rd delivery format - #41
Merged
Conversation
- Implement all 5 hooks: session-created, chat-message, tool-execute-after, session-compacted, session-idle - Port shell logic to TypeScript using OpenCode's plugin API - Oracle Gate 1 passed with remediation: * Fixed Bash case to log description + command (not command twice) * Removed jq check (not needed in TypeScript) * Used join() for path construction consistency * Replaced dynamic import with readFileSync in session-idle Deepwork: .slim/deepwork/opencode-plugin.md
- Create src/index.ts with structural typing for OpenCode plugin API - Wire all 5 hooks: session-created, chat-message, tool-execute-after, session-compacted, session-idle - Add package.json with TypeScript config - Add .gitignore to exclude node_modules - TypeScript compiles cleanly with no errors
- Add tsconfig.json with proper NodeNext module config - Fix package.json main field to point to dist/index.js - Add dist/ to .gitignore All three blockers from Oracle Gate 2 review resolved.
- 50 unit tests for redaction logic (PEM keys, tokens, auth schemes, keywords) - 21 integration tests for all 5 hooks - Tests cover edge cases, error handling, and buffer file creation - All tests pass with npm test
Ensures TLREDACTSENTINEL never appears in final output after URL userinfo redaction. Catches regression if unmaskSentinel() is skipped or broken.
- Update README intro to mention both Claude Code and OpenCode - Add OpenCode Plugin section with installation instructions - Update Layout section to include .opencode-plugin/ directory - Add Unreleased section to CHANGELOG with OpenCode plugin details
Every one of the 5 hooks was either a silent no-op or threw, because src/index.ts hand-rolled structural interfaces for OpenCode's plugin API instead of importing the real ones from @opencode-ai/plugin, and every guess was wrong against the installed SDK (opencode 1.18.18): - event.sessionID doesn't exist; it's event.properties.sessionID (or event.properties.info.id for session.created), so session.created/ idle/compacted never fired. - UserMessage has no `parts` field; chat.message's real payload carries text in the sibling output.parts, so every chat.message call threw. - OpenCode's built-in tool ids are lowercase (bash/edit/write/grep/ webfetch/websearch/task), not Claude Code's PascalCase names, so tool.execute.after matched nothing and captured nothing. - The HANDOFF.md context block session-created assembled was discarded; wired it through experimental.chat.system.transform instead, since OpenCode has no direct SessionStart-style injection channel. Also fixes two bugs surfaced while writing real regression coverage: - chat-message.ts never created the buffer directory before writing (unlike tool-execute-after.ts), so a session's opening prompt - always the first capture event - was silently dropped. - session-idle's one-shot idempotency guard assumed Claude Code's SessionEnd semantics; OpenCode's session.idle actually fires after every turn, so the first idle permanently stamped an active session "ended". Replaced with a strip-and-restamp that keeps exactly one marker, always at the true end. Packaging/CI: - package.json main now points at src/index.ts (OpenCode loads plugin TS directly via Bun, matching how sibling plugins in this config are installed) and test builds before running; deleted the contradictory plugin.json, which OpenCode's loader never reads. - Added a CI job that actually typechecks and runs the plugin's tests - previously only the shell hooks were linted, so "72 tests passing" had never been verified anywhere but one machine. - README's OpenCode install section described a plugins/ directory and a "plugins" config key that don't exist; corrected to the real `plugin` (singular) array and local-path install this repo actually uses until it's published to npm. Test suite rewritten to drive the plugin through real SDK-shaped payloads (event envelopes, UserMessage/parts, lowercase tool ids) end-to-end via ThroughlinePlugin() itself, not hand-shaped fixtures that happened to match the bugs. The old suite's escape-hatch pattern (assert only `if (existsSync(bufferFile))`, else assert.ok(true)) let every content assertion silently no-op - the guessed buffer filenames didn't even account for tlSafeSid's actual sanitization rules, so no real capture output was ever checked. 83 tests now pass (51 unit + 32 integration) with unconditional assertions.
This was referenced Aug 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds OpenCode as a 3rd delivery format for throughline, alongside the existing Claude Code plugin and NPX skills.
Update: the initial implementation had every one of its 5 hooks broken — the OpenCode API was hand-rolled as guessed structural types rather than imported from
@opencode-ai/plugin, and every guess was wrong against the installed SDK (opencode 1.18.18). This PR now fixes all of that; see "Fixed in review" below.What's included
Plugin implementation (
.opencode-plugin/)session-created— bootstraps the data dir, assembles the HANDOFF.md pointer + live git state blockchat-message— captures redacted user promptstool-execute-after— captures tool actions (bash, edit, write, grep, webfetch, websearch, task)session-compacted— stamps compaction boundary markerssession-idle— stamps a "last known idle point" markerexperimental.chat.system.transform— OpenCode has no direct equivalent of Claude Code's SessionStart context injection today, so this rides anexperimental.*API that may need to move if it changes upstream.@opencode-ai/sdkpayload shapes end-to-endDocumentation
pluginconfig key, real install steps, honest description of the experimental injection hook and the per-turnsession.idlesemantics)Fixed in review
All found by checking the code against
node_modules/@opencode-ai/{plugin,sdk}'s actual type definitions and OpenCode's own docs/source, not assumption:event.sessionIDdoesn't exist on OpenCode's event shape — it'sevent.properties.sessionID(event.properties.info.idforsession.created).session.created/idle/compactedwere dead code.chat.messagethrew on every call.UserMessagehas nopartsfield; the real payload carries text in the siblingoutput.parts.tool.execute.aftercaptured nothing. OpenCode's built-in tool ids are lowercase (bash,edit,write,grep,webfetch,websearch,task, ...), not Claude Code's PascalCase names. Also dropped the Claude-onlyNotebookEdit/Agentcases and thedescriptionarg onbash(OpenCode's bash tool has no such field).session-created's return value was discarded. Wired throughexperimental.chat.system.transform, injected once per session.chat-message.tsnever created the buffer dir before writing (unliketool-execute-after.ts), so a session's opening prompt — always the first capture event — was silently dropped.session-idle's idempotency guard assumed Claude Code's SessionEnd semantics (fires once, at exit). OpenCode'ssession.idleactually fires after every turn — the old guard would permanently stamp an active session "ended" on its first turn. Replaced with strip-and-restamp: exactly one marker, always at the true end.plugin.json/package.jsondisagreed on the entry point and OpenCode doesn't readplugin.jsonat all — deleted it;package.jsonnow pointsmainatsrc/index.ts(OpenCode loads plugin TS directly via Bun).if (existsSync(bufferFile)) { assert } else { assert.ok(true) }, and the guessed buffer filenames didn't even matchtlSafeSid's real sanitization — so no assertion ever actually ran. Rewrote to drive the plugin through real SDK-shaped payloads end-to-end and assert unconditionally.ciworkflow had been manually disabled repo-wide since 2026-07-08 — re-enabled it. Also added a Node job (typecheck + test) alongside the existing shell job; previously only the shell hooks were linted, so "72 tests passing" had never been verified anywhere but one machine.Re-enabling CI surfaced a second, unrelated pre-existing issue in the shell hook's worktree-simulation tests (
tests/run.sh) — filed as #42, not blocking this PR (the newopencode-pluginjob is green).Key differences from Claude Code plugin
.claude/throughline/experimental.chat.system.transformsession.idle) — no true exit eventTesting
cd .opencode-plugin && npm run typecheck && npm test— 83/83 passing.Not yet verified: a live OpenCode restart with the plugin actually loaded (tracked in jsirish/opencode-config#4, which installs this locally).
Related