Skip to content

feat(plugin): add opencode AI plugin with session-id tracking#33

Merged
artyomsv merged 2 commits into
masterfrom
feature/plugin-opencode
May 22, 2026
Merged

feat(plugin): add opencode AI plugin with session-id tracking#33
artyomsv merged 2 commits into
masterfrom
feature/plugin-opencode

Conversation

@artyomsv

Copy link
Copy Markdown
Owner

Summary

Adds opencode as the second production AI pane type alongside claude-code. Tracks opencode's session-id rotation (new session, /new, fork, compaction) so panes resume to the same conversation across daemon restarts — the central value-prop of Quil for AI workflows.

The trick: opencode has a real first-class plugin runtime (Bun-based, 25+ lifecycle events) that we hook via the OPENCODE_CONFIG_CONTENT env var. This means no writes into ~/.config/opencode/ — the plugin file lives entirely under $QUIL_HOME/opencodehook/. OPENCODE_CONFIG_CONTENT merges with the user's existing opencode config (verified
against opencode 1.14.x), so their plugins, agents, and modes remain active inside Quil-spawned opencode panes.

How it works

  1. Daemon start writes the embedded JS plugin to $QUIL_HOME/opencodehook/quil-session-tracker.js via opencodehook.EnsureScripts (atomic temp+rename, mirrors claudehook).
  2. Pane spawn injects three env vars on the PTY:
    • QUIL_PANE_ID=<paneID>
    • QUIL_HOME=<absolute-path> (absolutized at the boundary so a relative QUIL_HOME cannot silently break tracking)
    • OPENCODE_CONFIG_CONTENT='{"plugin":["<abs-path-to-quil-session-tracker.js>"]}'
  3. Plugin hooks session.created / session.updated / session.idle / session.compacted / session.deleted events from opencode's bus, extracts event.properties.sessionID (with fallback to event.properties.info.id for forward-compatibility with the SDK type defs), and atomically writes $QUIL_HOME/sessions/opencode-<paneID>.id.
  4. Daemon restore reads that file via opencodehook.ReadPersistedSessionID and promotes the resume args to ["--session", "{session_id}"]; falls back to the plugin's configured ["--continue"] if no id was recorded yet.

Files

Area Files
New package internal/opencodehook/ (opencodehook.go, opencodehook_test.go, scripts/quil-session-tracker.js, o_nofollow_unix.go, o_nofollow_windows.go)
New plugin TOML internal/plugin/defaults/opencode.toml
Daemon wiring internal/daemon/daemon.go (EnsureScripts call, opencodeSpawnPrep, opencodeResumeTemplate, refactored resumeTemplateFor into per-plugin dispatch, templateHasPlaceholder helper)
Tests internal/daemon/spawn_args_test.go (6 new cases) + 29 cases in opencodehook_test.go
Docs .claude/CLAUDE.md, docs/plugin-reference.md, CHANGELOG.md
Backport internal/claudehook/claudehook.go (one comment improving cleanup intent)
Build artifacts .gitignore (added /quil-dev, /quild-dev, /quil-debug, /quild-debug)

Hardening applied during review

  • TOCTOU closedReadPersistedSessionID uses O_NOFOLLOW (build-tagged constant for portability) instead of Lstat-then-Open
  • Shape validation on readopencodehook.IsValidSessionID (Go mirror of the JS plugin's regex) filters corrupted ids before they reach --session <arg>
  • Absolute-path enforcementBuildConfigContent rejects relative paths so a non-absolute QUIL_HOME cannot silently break tracking when prompts_cwd puts opencode in a user-chosen directory
  • JS log rotation$QUIL_HOME/opencodehook/hook.log capped at 1 MB with single rotation; writes de-duplicated by last recorded id so session.updated bursts during a single response don't thrash the disk
  • JS plugin defensive codingtypeof sessionId === "string" guard, quilHome.includes("\0") rejection
  • Pane-id regex alignment — Go paneIDRe and JS PANE_ID_RE aligned so a future pane-id format change cannot silently disable tracking via JS-only rejection
  • templateHasPlaceholder — fixes a real bug where session_scrape with empty PluginState dropped its --continue fallback on restore
  • NUL byte in ValidateQuilDir's reject set — would otherwise truncate env vars silently on the C side
  • atomicWrite Chmod-before-Write — closes the umask-derived Windows window where the temp file briefly has 0644

Test plan

  • go vet ./... — clean
  • go test ./... (full suite, 17 packages) — all green
  • go test -race on internal/opencodehook/, internal/daemon/, internal/claudehook/ — clean
  • GOOS=windows go build ./... — clean (validates o_nofollow_unix.go / o_nofollow_windows.go build-tag split)
  • node --check internal/opencodehook/scripts/quil-session-tracker.js — JS syntax valid
  • Manual end-to-end smoke (macOS arm64):
    • Daemon write of plugin file to $QUIL_HOME/opencodehook/
    • Opencode picks up plugin via OPENCODE_CONFIG_CONTENT (verified via opencode debug config showing source: OPENCODE_CONFIG_CONTENT)
    • Session-id recorded on first reply
    • Daemon restart respawns pane with --session <id> arg (verified in spawn log)
    • After deleting the recorded id, restart falls back to --continue (verified in spawn log)
    • --print-logs toggle preserved across restart

Caveats / Future work

  • No SQLite probe for stale session ids — opencode handles stale ids itself with a clear error; we may revisit if it proves too noisy. SQLite path documented in code: ~/.local/share/opencode/opencode.db.
  • Idle handler patterns in opencode.toml are copied from claude-code; should be tuned after dogfooding against opencode's actual prompts.
  • --pure toggle deliberately not exposed — would disable external plugins including our tracker.

artyomsv and others added 2 commits May 22, 2026 13:40
Second production AI pane type alongside claude-code. Tracks opencode
session-id rotation (new session, /new, fork, compaction) via a small JS
plugin loaded through OPENCODE_CONFIG_CONTENT at pane spawn. The plugin
lives entirely under $QUIL_HOME/opencodehook/ — no writes into
~/.config/opencode/ — and OPENCODE_CONFIG_CONTENT merges with the user's
existing opencode config (verified against opencode 1.14.x) so their
plugins, agents, and modes remain active.

Implementation mirrors internal/claudehook/ with measured hardening:

  * O_NOFOLLOW on ReadPersistedSessionID closes the Lstat-then-Open
    TOCTOU window (build-tagged constant; no-op on Windows where
    symlink creation requires elevated privilege)
  * BuildConfigContent rejects relative scriptPath so a non-absolute
    QUIL_HOME cannot silently break tracking under prompts_cwd
  * opencodeResumeTemplate shape-validates the recorded id via
    opencodehook.IsValidSessionID (Go mirror of the JS plugin's regex)
    before promoting — corrupted files cannot inject text into argv
  * JS plugin caps hook.log at 1 MB with a single rotation,
    de-duplicates writes so session.updated bursts don't thrash disk,
    logs one "recorded <event-type> session=<id>" per id change
  * Pane-id validation aligned on both sides via regex so a future
    pane-id format change cannot silently disable tracking via
    JS-only rejection
  * Static-template resume args (--continue with empty PluginState)
    pass through the restore-args gate via the new templateHasPlaceholder
    helper, fixing a real bug where a fresh opencode pane closed
    before its first session event respawned with empty args

Tests: 29 cases in internal/opencodehook/ (validation, install
idempotency, JSON round-trip, IsValidSessionID, symlink rejection,
relative-path rejection, ScriptPath) and 6 new cases in
internal/daemon/spawn_args_test.go (resume promotion, fallback paths,
shape-validation guard, templateHasPlaceholder, JSON parse-back).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaces the new built-in plugin on the public site:
  * plugins.ts gets an opencode entry with the new `beta?: boolean`
    field — describes the OPENCODE_CONFIG_CONTENT injection model, the
    no-touch-on-user-config guarantee, --session/--continue restore
    behavior, and why --pure is deliberately not exposed as a toggle
  * plugins.astro renders a BETA badge next to the kind tag when
    `beta` is set; SEO meta + schema description updated from "four
    built-in pane types" to "five" with opencode named
  * index.astro § "Yours in 30 lines of TOML" body lists opencode as
    the fifth built-in type with a (beta) marker
  * features.ts "AI session resume" detail now mentions OpenCode (beta)
    alongside Claude Code (production); "Typed panes" detail bumped to
    five built-in pane types

`astro check` clean, `astro build` produces all 11 pages without warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@artyomsv artyomsv merged commit 13018b7 into master May 22, 2026
5 checks passed
@artyomsv artyomsv deleted the feature/plugin-opencode branch May 28, 2026 15:31
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