Skip to content

feat: add OpenCode plugin as 3rd delivery format - #41

Merged
jsirish merged 8 commits into
mainfrom
feat/opencode-plugin
Aug 18, 2026
Merged

feat: add OpenCode plugin as 3rd delivery format#41
jsirish merged 8 commits into
mainfrom
feat/opencode-plugin

Conversation

@jsirish

@jsirish jsirish commented Aug 18, 2026

Copy link
Copy Markdown
Member

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/)

  • 5 hooks ported from shell to TypeScript:
    • session-created — bootstraps the data dir, assembles the HANDOFF.md pointer + live git state block
    • chat-message — captures redacted user prompts
    • tool-execute-after — captures tool actions (bash, edit, write, grep, webfetch, websearch, task)
    • session-compacted — stamps compaction boundary markers
    • session-idle — stamps a "last known idle point" marker
  • HANDOFF.md context is injected into the system prompt via experimental.chat.system.transform — OpenCode has no direct equivalent of Claude Code's SessionStart context injection today, so this rides an experimental.* API that may need to move if it changes upstream.
  • Redaction logic ported from jq to TypeScript (no jq dependency)
  • Worktree-aware data directory resolution (shares main tree data), now keyed per-directory rather than cached globally
  • 83 tests passing (51 unit + 32 integration), driven through real @opencode-ai/sdk payload shapes end-to-end

Documentation

  • Corrected README's OpenCode Plugin section (real plugin config key, real install steps, honest description of the experimental injection hook and the per-turn session.idle semantics)
  • Updated CHANGELOG.md

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 hooks never fired. event.sessionID doesn't exist on OpenCode's event shape — it's event.properties.sessionID (event.properties.info.id for session.created). session.created/idle/compacted were dead code.
  • chat.message threw on every call. UserMessage has no parts field; the real payload carries text in the sibling output.parts.
  • tool.execute.after captured 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-only NotebookEdit/Agent cases and the description arg on bash (OpenCode's bash tool has no such field).
  • HANDOFF.md injection didn't exist. session-created's return value was discarded. Wired through experimental.chat.system.transform, injected once per session.
  • chat-message.ts never created the buffer dir before writing (unlike tool-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's session.idle actually 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.json disagreed on the entry point and OpenCode doesn't read plugin.json at all — deleted it; package.json now points main at src/index.ts (OpenCode loads plugin TS directly via Bun).
  • Test suite asserted nothing. Every content check was wrapped if (existsSync(bufferFile)) { assert } else { assert.ok(true) }, and the guessed buffer filenames didn't even match tlSafeSid'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.
  • CI never ran. The ci workflow 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 new opencode-plugin job is green).

Key differences from Claude Code plugin

Feature Claude Code OpenCode
Implementation Shell scripts + jq TypeScript
Dependencies git, jq Node.js 18+
Hook system Claude Code hooks OpenCode plugin API
Data directory .claude/throughline/ Same (compatible)
Session-start context injection Native (SessionStart) experimental.chat.system.transform
End-of-session semantics One-shot (SessionEnd) Per-turn (session.idle) — no true exit event

Testing

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

- 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.
@jsirish
jsirish merged commit fc6a0bb into main Aug 18, 2026
1 of 2 checks passed
@jsirish
jsirish deleted the feat/opencode-plugin branch August 18, 2026 13:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant