Skip to content

feat(opencode): publishable npm package for the opencode plugin (Phase 3) - #359

Merged
wcatz merged 3 commits into
mainfrom
chore/opencode-npm-package
Aug 24, 2026
Merged

feat(opencode): publishable npm package for the opencode plugin (Phase 3)#359
wcatz merged 3 commits into
mainfrom
chore/opencode-npm-package

Conversation

@waltskinner

Copy link
Copy Markdown
Collaborator

The Phase 3 "one npm package" artifact: plugin/ghost-opencode/.

  • index.ts adapted from the go:embed'd plugin with ONE divergence class: binary resolution is PATH-based (GHOST_BIN ?? "ghost") instead of a baked absolute path — npm users install ghost separately, and opencode's own process resolves at spawn time
  • Everything else byte-equivalent to the managed local-file variant: session.status preference over legacy idle, FIFO-bounded debounce, fail-open logging, temp-JSONL materialization
  • package.json: zero runtime deps, files: ["index.ts"], Apache-2.0 matching repo LICENSE
  • README documents both channels and when to prefer each (npm = updates decoupled from ghost releases; local-file = offline installs managed/repaired by ghost mcp init --client opencode)
  • Publish is deliberately NOT wired into release.yml yet — do it manually (cd plugin/ghost-opencode && npm publish) or ask me to wire an npm-publish job gated on tags first

@coderabbitai review

…e 3)

plugin/ghost-opencode/: index.ts adapted from the go:embed'd source with
PATH-based binary resolution (GHOST_BIN override, no baked absolute path —
npm users install ghost separately), package.json (no runtime deps,
files limited to index.ts), README covering both install channels, and
.gitignore. Spec §4 Phase 3 'one npm package' artifact.

Signed-off-by: agentskinner <agentskinner@proton.me>
@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6ba4ee5d-c1ab-485c-ab7d-29a81ed0fef9


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@wcatz

wcatz commented Aug 24, 2026

Copy link
Copy Markdown
Owner

/review

@github-actions

Copy link
Copy Markdown

Preparing review...

@wcatz

wcatz commented Aug 24, 2026

Copy link
Copy Markdown
Owner

/review

@github-actions

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Race condition in debounce

fireStopHook reads lastFire, computes Date.now(), and then sets the value in separate non-atomic steps. Because the event handler is async and shares the module-level lastFire Map, concurrent session.status events for the same session can interleave and fire multiple stop hooks inside the 2-second debounce window. In rapid idle transitions or clustered events, this breaks the "one stop hook per session per window" guarantee.

const now = Date.now()
if (now - (lastFire.get(sessionID) ?? 0) < DEBOUNCE_MS) return
lastFire.set(sessionID, now)
if (lastFire.size > MAX_TRACKED_SESSIONS) {
	const oldest = lastFire.keys().next().value
	if (oldest !== undefined) lastFire.delete(oldest)
}
Race between status and idle events

sawStatusEvent is a module-level boolean that is checked and set without synchronization. If a legacy session.idle event and a modern session.status event are handled concurrently, the idle check can read sawStatusEvent === false before the status handler sets it to true, causing both events to fire a stop hook for the same session.

if (event.type === "session.status") {
	sawStatusEvent = true
	const props = event.properties as { sessionID?: string; status?: { type?: string } }
	if (props?.status?.type !== "idle") return
	await fireStopHook(props.sessionID ?? "")
	return
}
if (event.type === "session.idle" && !sawStatusEvent) {
	const props = event.properties as { sessionID?: string }
	await fireStopHook(props?.sessionID ?? "")
}
Config hook clobbers ghost MCP entry

The config hook unconditionally assigns cfg.mcp["ghost"] = { type: "local", command: [GHOST_BIN, "mcp"], enabled: true }. If the user has already configured a custom ghost MCP server or disabled the default entry, the plugin silently overwrites it. This can break user-managed MCP setups.

config: async (cfg) => {
	cfg.mcp = cfg.mcp ?? {}
	cfg.mcp["ghost"] = {
		type: "local",
		command: [GHOST_BIN, "mcp"],
		enabled: true,
	}
},
Missing peer dependency for types

index.ts imports Plugin from @opencode-ai/plugin, but package.json declares no dependencies, peerDependencies, or devDependencies. Consumers who install ghost-opencode and run TypeScript checks will fail to resolve the type unless they happen to have already installed the opencode plugin package. Adding @opencode-ai/plugin as a peer or dev dependency would make the package self-contained for TypeScript users.

{
  "name": "ghost-opencode",
  "version": "0.1.0",
  "description": "opencode plugin for Ghost's persistent memory server: self-registers the ghost MCP server and bridges opencode's idle transition to the ghost host-event contract.",
  "license": "Apache-2.0",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/wcatz/ghost.git",
    "directory": "plugin/ghost-opencode"
  },
  "main": "./index.ts",
  "files": [
    "index.ts"
  ],
  "engines": {
    "node": ">=18"
  },
  "keywords": [
    "opencode",
    "opencode-plugin",
    "mcp",
    "memory",
    "ghost"
  ]
}

@wcatz

wcatz commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Reviewer dispositions:

  1. Debounce race — false positive. lastFire.get/.set are separated by no await; JS run-to-completion makes the check-and-set atomic against other event handlers.
  2. sawStatusEvent race — false positive. sawStatusEvent = true is the first synchronous statement of the status branch, before any await; the idle branch reads it synchronously too. The claimed interleave cannot occur in Node's single-threaded loop.
  3. Config hook clobber — valid, minor. Unconditional overwrite of cfg.mcp["ghost"] defeats a user's custom entry. Consider setting only when absent (or honoring an existing enabled entry) before publish.
  4. Missing peer dependency — valid, minor. @opencode-ai/plugin should appear under peerDependencies (peerDependenciesMeta.optional if deliberate) so consumer typechecks resolve.

3+4 are worth fixing before npm publish; 1+2 need no change.

@wcatz
wcatz merged commit 771c9cf into main Aug 24, 2026
6 of 7 checks passed
@wcatz
wcatz deleted the chore/opencode-npm-package branch August 24, 2026 17:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants