This project is a V2-only OpenCode plugin. Read
architecture.md first; it explains the module boundaries
and the non-obvious file-safety/LSP rules.
bun run build also needs Node (it clears dist/ with node -e); CI installs it with actions/setup-node.
bun install
python3 -m venv .venv
.venv/bin/pip install -r python/requirements-test.txtPyright is only needed for ipynb_lsp diagnostics and the post-edit hook;
execution works without it. Install it with uv tool install pyright or
npm i -g pyright, or set pyrightPath.
The plugin never installs Python packages at runtime. Tests that only touch the edit/read services do not need Jupyter; execution tests use the venv and skip when it is unavailable.
bun run typecheck
bun run typecheck:tests
PATH="$PWD/.venv/bin:$PATH" bun test
bun run build
npm pack --dry-runbun run builddeletesdist/first; do not commit stale declaration files.bun testruns all unit/integration suites. Some execution tests are skipped when the venv is missing.- Use the repository's Bun, not the Windows npm shim, when working from WSL.
- Add
src/tools/<name>.tsusing the internaltool()helper. - Keep the executor thin: resolve options, call a service, format a concise
result, and call
rethrowPermissionDeniedin the catch. - Register the definition in
src/registrations.tsand add its permission name if it mutates or executes. - Update the hook sets in
src/notebook-hooks.tsif the tool changes the notebook or can introduce Python errors. - Add unit tests for the service and the tool adapter. Update the README tool
table and, when behavior changes,
docs/architecture.md. - Run typecheck, tests, and build.
Tool results should be token-efficient. Do not echo the source, old/new previews, or a diff unless a user-facing feature explicitly requires it.
Add fields to PluginOptionsSchema in src/plugin-options.ts, document them in
the README, and add defaults/precedence tests. Per-instance options belong in
ToolContext.pluginOptions; never write them to process.env. Environment
overrides are centralized: OPENCODE_IPYNB_PYTHON wins over the pythonPath
option, and OPENCODE_IPYNB_OPTIONS replaces the whole options object. Do not
read process.env for options inside tools/services.
- Every write or execution must reach the host through
ctx.permission.assert(via the tool contextask), and permission failures must propagate. Mixed tools may declare no tool-level permission, but every read/write branch asks the matching action. Permission resources include both the lexical and real path so a symlink alias cannot bypass a deny rule on the target. - Never bypass
PathServicefor notebook I/O; services also pin real paths before I/O. Sidecars and pyright temp files are the explicit exception: they have their own ancestor/target checks and must never touch notebooks directly. - Keep per-file locking keyed by the resolved real path so symlink aliases serialize.
- When spawning subprocesses, kill the process group on timeout/abort and
clean temp files in
finally. - Do not add a core/OpenCode patch for rendering. Notebook edits are plain tools; diagnostics use the LSP-compatible text block and metadata.
- The virtual document is built by
src/lsp.ts; keep position mapping zero-based internally and one-based in user-facing diagnostic text. - Diagnostics must keep the wrapper shape used by
opencode-v2-lsp-support:LSP errors detected in ...and<diagnostics file="...">. The inner lines addERROR [cell N, line L:C]; the external plugin does not parse blocks produced by other plugins, so this is wrapper-compatible, not machine-compatible. Keepmetadata.notebookDiagnosticswithcellIndex/line/character. Do not claim native edit-renderer metadata for custom tools. - Materialized files must stay inside the session directory (the external
lsptool's root) and must never follow a symlink outside it. They live next to the notebook (visibleopencode-ipynb-lsp-<hash>.py, not a dotfile) so pyright resolves sibling imports; dotfiles are excluded by pyright. Identity is based on the notebook's real path, not its lexical alias. Keep the symlink tests intest/unit/virtual-materialize.test.ts, the sibling import regression intest/integration/materialized-imports.test.ts, and the path tests intest/unit/path-service.test.ts. opencode-v2-lsp-supportis a separate plugin. Do not import its runtime; communicate through the materialized file + itslsptool.- The full navigation flow is documented in the README:
ipynb_lspoperation=materialize, thenlspwith 1-based positions from the cell map.
test/unit/covers services, path/symlink confinement, locks, diagnostics, hooks, options, and the V2 adapter.test/integration/contains execution/reproducibility fixtures and warm kernel tests.- Prefer a failing regression test before a fix. Security tests must cover both the normal path and the symlink/escape path.
- When changing the hook, test the changed/no-op matrix (
changed: false, output-only clears, failed tools,save: false).
bun run typecheck && bun run typecheck:tests && bun test && bun run buildnpm pack --dry-runincludesserver.js,dist/index.js,dist/lsp.js,dist/rpc.js,python/ipynb_runner.py, andCHANGELOG.md.- Remove stale
dist/artifacts before packing; the build script does this. - Update README/docs for any option, permission, or tool change.
- Keep the package V1-free: no
@opencode-ai/pluginimports and no V1server()export.
The services use Effect 4.0.6, matching OpenCode 2's plugin SDK. It is a
peerDependency: the build marks effect (and effect/*) external, so the
host and the plugin share one runtime. Keep new code on the Effect 4 API
(Effect.gen, Cause.findErrorOption, Data.TaggedError, etc.). Services are
plain constructors built per tool context; do not add unused Context.Service
tags.
Effect 4 conventions used here:
Effect.genfor inline composition; reusable effect functions useEffect.fnUntraced(seeKernelManager.execute).Effect.allwith a concurrency bound for independent work.Effect.tryPromisefor fallible async work andEffect.promiseonly for infallible async work.- Keep typed errors honest: do not use
mapError((e) => e as X)casts. Add the error to the service union instead. - Use
Effect.Semaphorefor per-file locks instead of running an Effect from inside another Effect. - At the tool boundary use
Effect.runPromiseExit+Cause.findErrorOption/Cause.squashso host permission errors propagate. - Prefer
Data.TaggedErrorfor domain errors and keep_tagchecks out of business logic.