From d33301e34a881654f832aea8efc5f6d5ab08a347 Mon Sep 17 00:00:00 2001 From: Binfeng Xu Date: Fri, 8 May 2026 11:54:48 -0400 Subject: [PATCH 1/7] opencode obsv plugin --- .gitignore | 2 + docs/index.md | 1 + docs/integrate-frameworks/about.md | 1 + docs/integrate-frameworks/opencode.md | 271 ++++ integrations/opencode-plugin/README.md | 76 ++ .../opencode-plugin/package-lock.json | 1185 +++++++++++++++++ integrations/opencode-plugin/package.json | 55 + integrations/opencode-plugin/server.js | 509 +++++++ .../opencode-plugin/test/server.test.mjs | 346 +++++ opencode-nemoflow-integration-plan.md | 703 ++++++++++ 10 files changed, 3149 insertions(+) create mode 100644 docs/integrate-frameworks/opencode.md create mode 100644 integrations/opencode-plugin/README.md create mode 100644 integrations/opencode-plugin/package-lock.json create mode 100644 integrations/opencode-plugin/package.json create mode 100644 integrations/opencode-plugin/server.js create mode 100644 integrations/opencode-plugin/test/server.test.mjs create mode 100644 opencode-nemoflow-integration-plan.md diff --git a/.gitignore b/.gitignore index eba83c4f..e8cc9d3d 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,5 @@ CHANGELOG.md crates/wasm/coverage/ .scannerwork/ + +**/.nemoflow/ \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 8e05fcd8..6b53133c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -165,6 +165,7 @@ Advanced Guide: Handle Non-Serializable Data Advanced Guide: Provider Codecs Advanced Guide: Provider Response Codecs +OpenCode Plugin Code Examples ``` diff --git a/docs/integrate-frameworks/about.md b/docs/integrate-frameworks/about.md index a12a1b77..cd8361bf 100644 --- a/docs/integrate-frameworks/about.md +++ b/docs/integrate-frameworks/about.md @@ -41,6 +41,7 @@ Use these guide links to move from the overview into task-specific instructions. - [Advanced Guide: Using Codecs](using-codecs.md) explains typed value codecs for framework-facing wrappers. - [Advanced Guide: Provider Codecs](provider-codecs.md) explains provider request and response codecs for normalized middleware and event annotations. - [Advanced Guide: Provider Response Codecs](provider-response-codecs.md) focuses on response-only annotations for subscribers and exporters. +- [OpenCode Plugin](opencode.md) explains how to install and configure the standalone OpenCode observability plugin. - [Code Examples](code-examples.md) collects fallback APIs, mark events, and repository patch workflow examples. Start by identifying the framework's stable tool and LLM boundaries. Prefer diff --git a/docs/integrate-frameworks/opencode.md b/docs/integrate-frameworks/opencode.md new file mode 100644 index 00000000..9d7fe200 --- /dev/null +++ b/docs/integrate-frameworks/opencode.md @@ -0,0 +1,271 @@ + + +# OpenCode Plugin + +NeMo Flow integrates with OpenCode through a standalone server plugin. The +plugin uses OpenCode's public plugin hooks and does not require a patched +OpenCode checkout. + +Use this plugin when you want NeMo Flow observability for OpenCode sessions, +messages, LLM request metadata, successful tool calls, and session errors. + +## What You Build + +You will configure stock OpenCode to load the NeMo Flow plugin in the +background. After that, you can use OpenCode normally through the interactive +interface or `opencode run`. The plugin observes OpenCode hooks and writes +NeMo Flow ATOF and ATIF files under the OpenCode project directory. + +```{mermaid} +flowchart LR + User[Developer] + OpenCode[Stock OpenCode] + Plugin[NeMo Flow
OpenCode plugin] + Runtime[NeMo Flow
Node.js binding] + ATOF[(ATOF JSONL)] + ATIF[(ATIF JSON)] + + User -->|uses normally| OpenCode + OpenCode -->|public plugin hooks| Plugin + Plugin -->|scopes, marks,
tool lifecycle| Runtime + Runtime -->|append events| ATOF + Runtime -->|export on idle
or deleted session| ATIF + + class User blue-lightest; + class OpenCode green-lightest; + class Plugin purple-lightest; + class Runtime green-light; + class ATOF yellow-lightest; + class ATIF yellow-lightest; +``` + +The plugin is passive. It records observability output but does not rewrite +prompts, tool arguments, model requests, or OpenCode execution behavior. + +## Install + +Build the NeMo Flow Node.js binding before loading the plugin from a source +checkout. `crates/node` is under the NeMo Flow repository root: + +```bash +export NEMO_FLOW_REPO=/absolute/path/to/NeMo-Flow +cd "$NEMO_FLOW_REPO/crates/node" +npm install +npm run build +``` + +For local development, install or use stock OpenCode and point `opencode.json` +at the plugin directory: + +```bash +npm install -g opencode-ai@latest +opencode --version +``` + +When the plugin package is published, use +`@nvidia/nemoflow-opencode-plugin` in the OpenCode config instead of the local +file URL. + +## Configure OpenCode + +Create or update `opencode.json` in the OpenCode project directory: + +```json +{ + "plugin": [ + [ + "file:///absolute/path/to/NeMo-Flow/integrations/opencode-plugin", + { + "enabled": true, + "atofPath": "./.nemoflow/opencode.atof.jsonl", + "atifPath": "./.nemoflow/opencode.atif.json", + "logPath": "./.nemoflow/opencode-plugin.log" + } + ] + ] +} +``` + +The paths are resolved relative to the OpenCode project directory. If +`nemo-flow-node` is missing or cannot initialize, the plugin logs one warning +and returns no hooks, so OpenCode continues in pass-through mode. + +## Run the Demo + +Use this demo when you want to show the integration end to end. It uses a +source checkout plugin path because the package is not published yet. + +```bash +export NEMO_FLOW_REPO=/absolute/path/to/NeMo-Flow +export NEMO_FLOW_DEMO_DIR="$NEMO_FLOW_REPO/tmp/opencode-nemoflow-demo" + +rm -rf "$NEMO_FLOW_DEMO_DIR" +mkdir -p "$NEMO_FLOW_DEMO_DIR/.nemoflow" +cd "$NEMO_FLOW_DEMO_DIR" + +cat > opencode.json <>OC: Start OpenCode in a project + OC->>Plug: config(input) + Plug->>Files: Write plugin diagnostics + Dev->>OC: Send a prompt or run a task + OC->>Plug: chat.message and chat.params + Plug->>NF: Emit session and LLM request marks + NF->>Files: Append ATOF JSONL + OC->>Plug: tool.execute.before and after + Plug->>NF: Open and close tool lifecycle records + NF->>Files: Append ATOF JSONL + OC->>Plug: session.status idle or session.deleted + Plug->>NF: Flush session trajectory + NF->>Files: Write ATIF JSON +``` + +## Pass-Through Checks + +The plugin should not change OpenCode behavior when observability is disabled +or when the NeMo Flow runtime is unavailable. + +Disable the plugin: + +```bash +cp opencode.json opencode.enabled.json +jq '(.plugin[0][1].enabled) = false' opencode.json > opencode.disabled.json +mv opencode.disabled.json opencode.json +rm -f ./.nemoflow/opencode.* + +opencode run --title "nemo-flow disabled smoke" \ + "Reply with exactly: plugin disabled smoke." + +test ! -s ./.nemoflow/opencode.atof.jsonl +test ! -s ./.nemoflow/opencode.atif.json +mv opencode.enabled.json opencode.json +``` + +Force runtime initialization failure: + +```bash +rm -f ./.nemoflow/opencode.* + +NEMO_FLOW_OPENCODE_FORCE_INIT_FAILURE=1 opencode run \ + --title "nemo-flow init failure smoke" \ + "Reply with exactly: init failure smoke." + +grep -i "pass-through" ./.nemoflow/opencode-plugin.log +test ! -s ./.nemoflow/opencode.atof.jsonl +``` + +## Demo Video Script + +Use this storyboard to record a short walkthrough. + +| Shot | Show | Narration | +|---|---|---| +| 1 | `opencode.json` with the plugin file URL | Stock OpenCode loads the NeMo Flow plugin through normal plugin config. | +| 2 | `opencode debug info` output | OpenCode sees the plugin without applying an OpenCode patch. | +| 3 | `opencode run` or the interactive OpenCode UI | The developer uses OpenCode normally. | +| 4 | `ls -la .nemoflow` | The plugin writes observability files in the background. | +| 5 | `grep` against `opencode.atof.jsonl` | ATOF contains session, message, LLM request, and tool lifecycle events. | +| 6 | `jq` against `opencode.atif.json` | ATIF contains the exported session trajectory. | +| 7 | Disabled or forced-failure smoke | OpenCode still runs when the plugin is disabled or pass-through. | + +Keep the recording focused on the user-visible contract: install the plugin, +use OpenCode normally, and inspect `.nemoflow` output after the session. + +## Limits + +The current OpenCode plugin API is enough for passive observability. It is not +enough for NeMo Flow request intercepts, execution intercepts, conditional +blocking, or complete tool error spans because OpenCode does not yet expose +around-style LLM or tool hooks. Future work should add generic OpenCode plugin +hooks upstream before enabling those behaviors. diff --git a/integrations/opencode-plugin/README.md b/integrations/opencode-plugin/README.md new file mode 100644 index 00000000..1eea1333 --- /dev/null +++ b/integrations/opencode-plugin/README.md @@ -0,0 +1,76 @@ + + +# NeMo Flow OpenCode Plugin + +This package is a standalone OpenCode server plugin for NeMo Flow observability. +It uses OpenCode's public plugin API and does not require patching OpenCode. + +For the illustrated setup guide and demo recording script, see +`docs/integrate-frameworks/opencode.md` in the NeMo Flow source checkout. + +## Configuration + +Use the plugin from an OpenCode config file. From a NeMo Flow source checkout, +use a file URL: + +```json +{ + "plugin": [ + [ + "file:///absolute/path/to/NeMo-Flow/integrations/opencode-plugin", + { + "enabled": true, + "atofPath": "./.nemoflow/opencode.atof.jsonl", + "atifPath": "./.nemoflow/opencode.atif.json", + "logPath": "./.nemoflow/opencode-plugin.log" + } + ] + ] +} +``` + +When this package is published, replace the file URL with the package name: + +```json +{ + "plugin": [ + [ + "@nvidia/nemoflow-opencode-plugin", + { + "enabled": true, + "atofPath": "./.nemoflow/opencode.atof.jsonl", + "atifPath": "./.nemoflow/opencode.atif.json", + "logPath": "./.nemoflow/opencode-plugin.log" + } + ] + ] +} +``` + +The package loads `nemo-flow-node` dynamically. If the native Node binding is +missing or cannot initialize, the plugin logs one pass-through warning and does +not change OpenCode behavior. + +## Compatibility + +The plugin declares support for OpenCode `>=1.14.40`. It uses the public +OpenCode server plugin hooks that are available in `@opencode-ai/plugin` +`1.14.40` and were verified against OpenCode `1.14.41`. + +## Output + +- `atofPath` receives raw NeMo Flow ATOF JSONL events for OpenCode session, + message, LLM request metadata, error, and successful tool lifecycle records. +- `atifPath` receives a session trajectory when OpenCode reports a session as + idle or deleted. +- `logPath` receives JSONL plugin diagnostics. + +## Current Limitations + +This plugin uses only existing OpenCode hooks. OpenCode does not yet expose an +around-style LLM stream hook or tool execution hook, so the plugin cannot record +exact LLM stream duration, tool error spans for every failure path, request +intercepts, execution intercepts, or conditional guardrail blocking. diff --git a/integrations/opencode-plugin/package-lock.json b/integrations/opencode-plugin/package-lock.json new file mode 100644 index 00000000..eba44d2e --- /dev/null +++ b/integrations/opencode-plugin/package-lock.json @@ -0,0 +1,1185 @@ +{ + "name": "@nvidia/nemoflow-opencode-plugin", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@nvidia/nemoflow-opencode-plugin", + "version": "0.2.0", + "license": "Apache-2.0", + "dependencies": { + "nemo-flow-node": "file:../../crates/node" + }, + "devDependencies": { + "@opencode-ai/plugin": "^1.14.40" + }, + "engines": { + "node": ">=20.0.0", + "opencode": ">=1.14.40" + }, + "peerDependencies": { + "@opencode-ai/plugin": ">=1.14.40" + } + }, + "../../crates/node": { + "name": "nemo-flow-node", + "version": "0.2.0", + "license": "Apache-2.0", + "devDependencies": { + "@napi-rs/cli": "^2", + "c8": "^11.0.0", + "prettier": "^3.8.2", + "typedoc": "^0.28.0", + "typescript": "^5.8.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "../../crates/node/node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "../../crates/node/node_modules/@gerrit0/mini-shiki": { + "version": "3.23.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-oniguruma": "^3.23.0", + "@shikijs/langs": "^3.23.0", + "@shikijs/themes": "^3.23.0", + "@shikijs/types": "^3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "../../crates/node/node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "../../crates/node/node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "../../crates/node/node_modules/@napi-rs/cli": { + "version": "2.18.4", + "dev": true, + "license": "MIT", + "bin": { + "napi": "scripts/index.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "../../crates/node/node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "../../crates/node/node_modules/@shikijs/langs": { + "version": "3.23.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "../../crates/node/node_modules/@shikijs/themes": { + "version": "3.23.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "../../crates/node/node_modules/@shikijs/types": { + "version": "3.23.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "../../crates/node/node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/@types/hast": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "../../crates/node/node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/@types/unist": { + "version": "3.0.3", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "../../crates/node/node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "../../crates/node/node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "../../crates/node/node_modules/brace-expansion": { + "version": "5.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "../../crates/node/node_modules/c8": { + "version": "11.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^8.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": "20 || >=22" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, + "../../crates/node/node_modules/cliui": { + "version": "8.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "../../crates/node/node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "../../crates/node/node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "../../crates/node/node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/entities": { + "version": "4.5.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "../../crates/node/node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "../../crates/node/node_modules/find-up": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "../../crates/node/node_modules/foreground-child": { + "version": "3.3.1", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "../../crates/node/node_modules/get-caller-file": { + "version": "2.0.5", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "../../crates/node/node_modules/glob": { + "version": "13.0.6", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "../../crates/node/node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/html-escaper": { + "version": "2.0.2", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "../../crates/node/node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/istanbul-lib-report": { + "version": "3.0.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "../../crates/node/node_modules/istanbul-reports": { + "version": "3.2.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/linkify-it": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "../../crates/node/node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "../../crates/node/node_modules/lru-cache": { + "version": "11.2.7", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "../../crates/node/node_modules/lunr": { + "version": "2.3.9", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/make-dir": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "../../crates/node/node_modules/markdown-it": { + "version": "14.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "../../crates/node/node_modules/mdurl": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/minimatch": { + "version": "10.2.5", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "../../crates/node/node_modules/minipass": { + "version": "7.1.3", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "../../crates/node/node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "../../crates/node/node_modules/p-locate": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "../../crates/node/node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/path-scurry": { + "version": "2.0.2", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "../../crates/node/node_modules/prettier": { + "version": "3.8.2", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "../../crates/node/node_modules/punycode.js": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "../../crates/node/node_modules/require-directory": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "../../crates/node/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "../../crates/node/node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "../../crates/node/node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/test-exclude": { + "version": "8.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "../../crates/node/node_modules/typedoc": { + "version": "0.28.19", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@gerrit0/mini-shiki": "^3.23.0", + "lunr": "^2.3.9", + "markdown-it": "^14.1.1", + "minimatch": "^10.2.5", + "yaml": "^2.8.3" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 18", + "pnpm": ">= 10" + }, + "peerDependencies": { + "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" + } + }, + "../../crates/node/node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "../../crates/node/node_modules/uc.micro": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/v8-to-istanbul": { + "version": "9.3.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "../../crates/node/node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "../../crates/node/node_modules/wrap-ansi": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "../../crates/node/node_modules/y18n": { + "version": "5.0.8", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "../../crates/node/node_modules/yaml": { + "version": "2.8.3", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "../../crates/node/node_modules/yargs": { + "version": "17.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "../../crates/node/node_modules/yargs-parser": { + "version": "21.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "../../crates/node/node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.14.41", + "dev": true, + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.14.41", + "effect": "4.0.0-beta.59", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.2.2", + "@opentui/solid": ">=0.2.2" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.14.41", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.59", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.6.0", + "find-my-way-ts": "^0.1.6", + "ini": "^6.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^1.11.9", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^13.0.0", + "yaml": "^2.8.3" + } + }, + "node_modules/fast-check": { + "version": "4.7.0", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "dev": true, + "license": "MIT" + }, + "node_modules/ini": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/msgpackr": { + "version": "1.11.12", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/multipasta": { + "version": "0.2.7", + "dev": true, + "license": "MIT" + }, + "node_modules/nemo-flow-node": { + "resolved": "../../crates/node", + "link": true + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pure-rand": { + "version": "8.4.0", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/toml": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/uuid": { + "version": "13.0.2", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yaml": { + "version": "2.8.4", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/integrations/opencode-plugin/package.json b/integrations/opencode-plugin/package.json new file mode 100644 index 00000000..e0281b3f --- /dev/null +++ b/integrations/opencode-plugin/package.json @@ -0,0 +1,55 @@ +{ + "name": "@nvidia/nemoflow-opencode-plugin", + "version": "0.2.0", + "description": "OpenCode server plugin that exports NeMo Flow observability data.", + "type": "module", + "main": "./server.js", + "exports": { + ".": { + "default": "./server.js", + "import": "./server.js" + }, + "./server": { + "default": "./server.js", + "import": "./server.js" + } + }, + "files": [ + "README.md", + "server.js" + ], + "scripts": { + "test": "node --test test/*.mjs" + }, + "keywords": [ + "opencode", + "nemo-flow", + "observability", + "atif", + "atof", + "plugin" + ], + "homepage": "https://github.com/NVIDIA/NeMo-Flow#readme", + "bugs": { + "url": "https://github.com/NVIDIA/NeMo-Flow/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NVIDIA/NeMo-Flow.git", + "directory": "integrations/opencode-plugin" + }, + "license": "Apache-2.0", + "engines": { + "node": ">=20.0.0", + "opencode": ">=1.14.40" + }, + "dependencies": { + "nemo-flow-node": "file:../../crates/node" + }, + "peerDependencies": { + "@opencode-ai/plugin": ">=1.14.40" + }, + "devDependencies": { + "@opencode-ai/plugin": "^1.14.40" + } +} diff --git a/integrations/opencode-plugin/server.js b/integrations/opencode-plugin/server.js new file mode 100644 index 00000000..ea4c77db --- /dev/null +++ b/integrations/opencode-plugin/server.js @@ -0,0 +1,509 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fsSync from "node:fs" +import fs from "node:fs/promises" +import path from "node:path" + +const PLUGIN_ID = "@nvidia/nemoflow-opencode-plugin" +const AGENT_VERSION = "opencode-plugin-0.2.0" +const RELEVANT_EVENTS = new Set([ + "session.created", + "session.updated", + "session.deleted", + "session.error", + "session.status", + "session.idle", + "message.updated", + "message.removed", + "message.part.updated", + "message.part.delta", + "message.part.removed", +]) + +function createLogger(logPath) { + const seen = new Set() + + async function write(level, message, extra) { + const record = { + timestamp: new Date().toISOString(), + level, + plugin: PLUGIN_ID, + message, + ...(extra === undefined ? {} : { extra: toJsonSafe(extra) }), + } + const line = JSON.stringify(record) + "\n" + if (logPath) { + await ensureParentDir(logPath) + await fs.appendFile(logPath, line) + return + } + const text = `[${PLUGIN_ID}] ${message}` + if (level === "error") console.error(text, extra ?? "") + else if (level === "warn") console.warn(text, extra ?? "") + else console.info(text, extra ?? "") + } + + return { + info: (message, extra) => write("info", message, extra), + warn: (message, extra) => write("warn", message, extra), + error: (message, extra) => write("error", message, extra), + warnOnce: (key, message, extra) => { + if (seen.has(key)) return Promise.resolve() + seen.add(key) + return write("warn", message, extra) + }, + } +} + +async function ensureParentDir(filePath) { + await fs.mkdir(path.dirname(filePath), { recursive: true }) +} + +function resolveOutputPath(baseDir, value) { + if (typeof value !== "string" || value.trim() === "") return undefined + if (path.isAbsolute(value)) return value + return path.resolve(baseDir, value) +} + +function normalizeOptions(input, options = {}) { + const baseDir = input?.directory ?? process.cwd() + return { + enabled: options.enabled !== false, + atofPath: resolveOutputPath(baseDir, options.atofPath ?? "./.nemoflow/opencode.atof.jsonl"), + atifPath: resolveOutputPath(baseDir, options.atifPath ?? "./.nemoflow/opencode.atif.json"), + logPath: resolveOutputPath(baseDir, options.logPath ?? "./.nemoflow/opencode-plugin.log"), + } +} + +function toJsonSafe(value) { + if (value === undefined) return null + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack, + } + } + + const seen = new WeakSet() + try { + return JSON.parse( + JSON.stringify(value, (key, nested) => { + if (/^(api[-_]?key|authorization|password|secret|access[-_]?token|refresh[-_]?token|id[-_]?token|token)$/i.test(key)) { + return "[Redacted]" + } + if (typeof nested === "bigint") return nested.toString() + if (typeof nested === "function") return `[Function ${nested.name || "anonymous"}]` + if (nested instanceof Error) return toJsonSafe(nested) + if (nested && typeof nested === "object") { + if (seen.has(nested)) return "[Circular]" + seen.add(nested) + } + return nested + }), + ) + } catch { + return null + } +} + +function modelName(model) { + if (!model) return undefined + const provider = model.providerID ?? model.provider?.id + const id = model.modelID ?? model.id + if (provider && id) return `${provider}/${id}` + if (id) return String(id) + return undefined +} + +function agentName(input, fallback = "opencode") { + if (typeof input?.agent === "string" && input.agent) return input.agent + if (typeof input?.message?.agent === "string" && input.message.agent) return input.message.agent + if (typeof input?.info?.agent === "string" && input.info.agent) return input.info.agent + return fallback +} + +function eventSessionID(event) { + const props = event?.properties + return props?.sessionID ?? props?.info?.id +} + +function inputSessionMetadata(sessionID, state) { + return { + source: "opencode", + sessionID, + agent: state.agent, + model: state.model, + } +} + +function eventMetadata(session, extra = {}) { + return { + agent: session?.agent, + model: session?.model, + ...extra, + } +} + +function shouldFlushEvent(event) { + if (!event) return false + if (event.type === "session.deleted" || event.type === "session.idle") return true + if (event.type !== "session.status") return false + return event.properties?.status?.type === "idle" +} + +function createNemoFlowAdapter(lib, options, logger) { + const sessions = new Map() + const recentFlushes = new Map() + const trajectories = [] + let atofSubscriberName + let atofDeregisterTimer + let closed = false + + function registerAtOfJsonlExporter() { + if (atofDeregisterTimer) { + clearTimeout(atofDeregisterTimer) + atofDeregisterTimer = undefined + } + if (atofSubscriberName || !options.atofPath) return + fsSync.mkdirSync(path.dirname(options.atofPath), { recursive: true }) + atofSubscriberName = `${PLUGIN_ID}:atof:${process.pid}:${Date.now()}` + lib.registerSubscriber(atofSubscriberName, (event) => { + fsSync.appendFileSync(options.atofPath, JSON.stringify(event) + "\n") + }) + void logger.info("registered ATOF JSONL exporter", { path: options.atofPath }) + } + + function deregisterAtOfJsonlExporter() { + if (atofDeregisterTimer) { + clearTimeout(atofDeregisterTimer) + atofDeregisterTimer = undefined + } + if (!atofSubscriberName) return + try { + lib.deregisterSubscriber(atofSubscriberName) + } catch (error) { + void logger.warnOnce("atof-deregister", "failed to deregister ATOF JSONL exporter", error) + } finally { + atofSubscriberName = undefined + } + } + + function scheduleAtOfJsonlExporterDeregister() { + if (!atofSubscriberName || atofDeregisterTimer) return + atofDeregisterTimer = setTimeout(() => { + deregisterAtOfJsonlExporter() + }, 250) + } + + function withStack(session, callback) { + if (!session.stack || typeof lib.setThreadScopeStack !== "function") return callback() + const previous = typeof lib.currentScopeStack === "function" ? lib.currentScopeStack() : undefined + lib.setThreadScopeStack(session.stack) + try { + return callback() + } finally { + if (previous) lib.setThreadScopeStack(previous) + } + } + + function ensureSession(sessionID, metadata = {}) { + if (!sessionID) return undefined + registerAtOfJsonlExporter() + + let session = sessions.get(sessionID) + if (session) { + if (metadata.agent) session.agent = metadata.agent + if (metadata.model) session.model = metadata.model + return session + } + + session = { + id: sessionID, + agent: metadata.agent ?? "opencode", + model: metadata.model, + stack: typeof lib.createScopeStack === "function" ? lib.createScopeStack() : undefined, + scope: undefined, + exporter: undefined, + exporterName: `${PLUGIN_ID}:atif:${sessionID}:${Date.now()}`, + pendingTools: new Map(), + } + + session.exporter = new lib.AtifExporter(session.id, session.agent, AGENT_VERSION, session.model ?? null) + session.exporter.register(session.exporterName) + session.scope = withStack(session, () => + lib.pushScope( + "opencode.session", + lib.ScopeType?.Agent ?? 0, + null, + null, + { sessionID }, + inputSessionMetadata(sessionID, session), + { sessionID, source: "opencode" }, + ), + ) + sessions.set(sessionID, session) + emitMark(session, "opencode.session.observed", { + sessionID, + agent: session.agent, + model: session.model, + }) + return session + } + + function emitMark(session, name, data, metadata = {}) { + if (!session?.scope) return + lib.event( + name, + session.scope, + toJsonSafe(data), + { + source: "opencode", + sessionID: session.id, + ...toJsonSafe(metadata), + }, + null, + ) + } + + function writeAtifFile() { + if (!options.atifPath) return + const payload = trajectories.length === 1 ? trajectories[0] : { trajectories } + fsSync.mkdirSync(path.dirname(options.atifPath), { recursive: true }) + fsSync.writeFileSync(options.atifPath, JSON.stringify(payload, null, 2)) + } + + function flushSession(sessionID, reason) { + const session = sessions.get(sessionID) + if (!session) return + recentFlushes.set(sessionID, Date.now()) + emitMark(session, "opencode.session.flush", { sessionID, reason }) + for (const [key, tool] of session.pendingTools) { + try { + lib.toolCallEnd( + tool.handle, + { status: "unknown", reason: "session flushed before tool.execute.after" }, + null, + { source: "opencode", sessionID, callID: tool.callID }, + ) + } catch (error) { + void logger.warnOnce(`tool-close:${key}`, "failed to close pending OpenCode tool span", error) + } + } + session.pendingTools.clear() + + if (session.scope) { + try { + withStack(session, () => lib.popScope(session.scope, { sessionID, reason }, null)) + } catch (error) { + void logger.warnOnce(`scope-pop:${sessionID}`, "failed to close OpenCode session scope", error) + } + } + + try { + trajectories.push(JSON.parse(session.exporter.exportJson())) + writeAtifFile() + } catch (error) { + void logger.warnOnce(`atif-export:${sessionID}`, "failed to export ATIF trajectory", error) + } + + try { + session.exporter.deregister(session.exporterName) + } catch (error) { + void logger.warnOnce(`atif-deregister:${sessionID}`, "failed to deregister ATIF exporter", error) + } + sessions.delete(sessionID) + if (sessions.size === 0) scheduleAtOfJsonlExporterDeregister() + } + + return { + async recordConfig(config) { + if (closed) return + await logger.info("observed OpenCode config", { + model: config?.model, + agents: config?.agent ? Object.keys(config.agent) : undefined, + }) + }, + + async recordEvent(event) { + if (closed || !RELEVANT_EVENTS.has(event?.type)) return + const sessionID = eventSessionID(event) + if (!sessionID) return + const recentFlushAt = recentFlushes.get(sessionID) + if (shouldFlushEvent(event) && recentFlushAt && Date.now() - recentFlushAt < 2000) return + const props = event.properties ?? {} + const session = ensureSession(sessionID, { + agent: agentName(props.info, undefined), + model: modelName(props.info?.model), + }) + emitMark( + session, + `opencode.${event.type}`, + { + id: event.id, + type: event.type, + properties: props, + }, + eventMetadata(session, { eventType: event.type }), + ) + if (shouldFlushEvent(event)) { + flushSession(sessionID, event.type) + } + }, + + async recordChatMessage(input, output) { + if (closed) return + const session = ensureSession(input.sessionID, { + agent: agentName(input), + model: modelName(input.model ?? output?.message?.model), + }) + if (!session) return + emitMark( + session, + "opencode.chat.message", + { + input, + message: output?.message, + parts: output?.parts, + }, + eventMetadata(session, { messageID: input.messageID ?? output?.message?.id }), + ) + }, + + async recordChatParams(input, output) { + if (closed) return + const session = ensureSession(input.sessionID, { + agent: agentName(input), + model: modelName(input.model), + }) + if (!session) return + emitMark( + session, + "opencode.llm.request", + { + sessionID: input.sessionID, + agent: input.agent, + provider: input.provider, + model: input.model, + message: input.message, + params: output, + limitation: "OpenCode Phase 1 hooks expose request metadata but not exact stream completion.", + }, + eventMetadata(session, { messageID: input.message?.id }), + ) + }, + + async recordToolBefore(input, output) { + if (closed) return + const session = ensureSession(input.sessionID) + if (!session) return + const args = toJsonSafe(output?.args) + const handle = lib.toolCall( + input.tool, + args, + session.scope, + null, + { sessionID: input.sessionID, callID: input.callID }, + { source: "opencode", sessionID: input.sessionID, callID: input.callID }, + input.callID, + null, + ) + session.pendingTools.set(input.callID, { handle, callID: input.callID, tool: input.tool, args }) + }, + + async recordToolAfter(input, output) { + if (closed) return + const session = ensureSession(input.sessionID) + if (!session) return + let pending = session.pendingTools.get(input.callID) + if (!pending) { + const args = toJsonSafe(input.args) + const handle = lib.toolCall( + input.tool, + args, + session.scope, + null, + { sessionID: input.sessionID, callID: input.callID }, + { source: "opencode", sessionID: input.sessionID, callID: input.callID, recovered: true }, + input.callID, + null, + ) + pending = { handle, callID: input.callID, tool: input.tool, args } + } + lib.toolCallEnd( + pending.handle, + toJsonSafe({ + title: output?.title, + output: output?.output, + metadata: output?.metadata, + }), + null, + { source: "opencode", sessionID: input.sessionID, callID: input.callID }, + null, + ) + session.pendingTools.delete(input.callID) + }, + + async close() { + closed = true + for (const sessionID of [...sessions.keys()]) { + flushSession(sessionID, "plugin-close") + } + deregisterAtOfJsonlExporter() + }, + } +} + +async function loadDefaultRuntime() { + if (process.env.NEMO_FLOW_OPENCODE_FORCE_INIT_FAILURE === "1") { + throw new Error("forced initialization failure") + } + const mod = await import("nemo-flow-node") + return mod.default ?? mod +} + +export function createServerPlugin({ loadRuntime = loadDefaultRuntime } = {}) { + return async function server(input, options) { + const normalized = normalizeOptions(input, options) + const logger = createLogger(normalized.logPath) + + if (!normalized.enabled) { + await logger.warnOnce("disabled", "NeMo Flow OpenCode plugin disabled by configuration") + return {} + } + + let adapter + try { + const lib = await loadRuntime() + adapter = createNemoFlowAdapter(lib, normalized, logger) + await logger.info("initialized NeMo Flow OpenCode plugin", { + atofPath: normalized.atofPath, + atifPath: normalized.atifPath, + }) + } catch (error) { + await logger.warnOnce( + "init-failed", + "NeMo Flow runtime unavailable; OpenCode plugin is running pass-through", + error, + ) + return {} + } + + return { + config: async (config) => adapter.recordConfig(config), + event: async ({ event }) => adapter.recordEvent(event), + "chat.message": async (hookInput, output) => adapter.recordChatMessage(hookInput, output), + "chat.params": async (hookInput, output) => adapter.recordChatParams(hookInput, output), + "tool.execute.before": async (hookInput, output) => adapter.recordToolBefore(hookInput, output), + "tool.execute.after": async (hookInput, output) => adapter.recordToolAfter(hookInput, output), + } + } +} + +export const server = createServerPlugin() + +export default { + id: PLUGIN_ID, + server, +} diff --git a/integrations/opencode-plugin/test/server.test.mjs b/integrations/opencode-plugin/test/server.test.mjs new file mode 100644 index 00000000..d254a546 --- /dev/null +++ b/integrations/opencode-plugin/test/server.test.mjs @@ -0,0 +1,346 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { describe, it } from "node:test" + +import { createServerPlugin } from "../server.js" + +function createFakeRuntime() { + const subscribers = new Map() + let counter = 0 + + function emit(event) { + for (const callback of subscribers.values()) callback(event) + } + + class AtifExporter { + constructor(sessionID, agentName, agentVersion, modelName) { + this.sessionID = sessionID + this.agentName = agentName + this.agentVersion = agentVersion + this.modelName = modelName + this.events = [] + this.callback = (event) => this.events.push(event) + } + + register(name) { + subscribers.set(name, this.callback) + } + + deregister(name) { + return subscribers.delete(name) + } + + exportJson() { + return JSON.stringify({ + session_id: this.sessionID, + agent: { + name: this.agentName, + version: this.agentVersion, + model_name: this.modelName, + }, + steps: this.events, + }) + } + } + + return { + ScopeType: { Agent: 0 }, + AtifExporter, + registerSubscriber(name, callback) { + subscribers.set(name, callback) + }, + deregisterSubscriber(name) { + return subscribers.delete(name) + }, + createScopeStack() { + return { id: `stack-${++counter}` } + }, + currentScopeStack() { + return { id: "current" } + }, + setThreadScopeStack(_stack) {}, + pushScope(name, scopeType, _parent, attributes, data, metadata, input) { + const handle = { + uuid: `scope-${++counter}`, + name, + scopeType, + attributes, + } + emit({ + kind: "scope", + category: "agent", + scope_category: "start", + uuid: handle.uuid, + name, + data, + metadata, + input, + }) + return handle + }, + popScope(handle, output) { + emit({ + kind: "scope", + category: "agent", + scope_category: "end", + uuid: handle.uuid, + name: handle.name, + data: output, + }) + }, + event(name, handle, data, metadata) { + emit({ + kind: "mark", + uuid: `mark-${++counter}`, + parent_uuid: handle?.uuid, + name, + data, + metadata, + }) + }, + toolCall(name, args, handle, attributes, data, metadata, toolCallID) { + const tool = { + uuid: `tool-${++counter}`, + name, + parentUuid: handle?.uuid, + toolCallID, + } + emit({ + kind: "scope", + category: "tool", + scope_category: "start", + uuid: tool.uuid, + name, + parent_uuid: handle?.uuid, + data: args, + metadata, + }) + return tool + }, + toolCallEnd(handle, result, data, metadata) { + emit({ + kind: "scope", + category: "tool", + scope_category: "end", + uuid: handle.uuid, + name: handle.name, + data: result ?? data, + metadata, + }) + }, + } +} + +async function makeTempDir() { + return fs.mkdtemp(path.join(os.tmpdir(), "nemo-flow-opencode-plugin-")) +} + +async function readJsonl(filePath) { + const content = await fs.readFile(filePath, "utf8") + return content + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)) +} + +describe("NeMo Flow OpenCode plugin", () => { + it("records OpenCode hooks to ATOF and flushes ATIF on idle", async () => { + const dir = await makeTempDir() + const server = createServerPlugin({ loadRuntime: async () => createFakeRuntime() }) + const hooks = await server( + { directory: dir }, + { + enabled: true, + atofPath: "./.nemoflow/opencode.atof.jsonl", + atifPath: "./.nemoflow/opencode.atif.json", + logPath: "./.nemoflow/opencode-plugin.log", + }, + ) + + await hooks.config?.({ model: "test-provider/test-model", agent: { build: {} } }) + await hooks["chat.message"]?.( + { + sessionID: "ses_1", + agent: "build", + model: { providerID: "test-provider", modelID: "test-model" }, + messageID: "msg_1", + }, + { + message: { id: "msg_1", role: "user", agent: "build" }, + parts: [{ id: "part_1", type: "text", text: "hello" }], + }, + ) + await hooks["chat.params"]?.( + { + sessionID: "ses_1", + agent: "build", + model: { providerID: "test-provider", id: "test-model" }, + provider: { source: "config", options: {} }, + message: { id: "msg_1" }, + }, + { temperature: 0, topP: 1, topK: 0, options: {} }, + ) + await hooks["tool.execute.before"]?.( + { tool: "write", sessionID: "ses_1", callID: "call_1" }, + { args: { path: "phase1-demo.txt" } }, + ) + await hooks["tool.execute.after"]?.( + { tool: "write", sessionID: "ses_1", callID: "call_1", args: { path: "phase1-demo.txt" } }, + { title: "Wrote file", output: "done", metadata: { ok: true } }, + ) + await hooks.event?.({ + event: { + id: "evt_1", + type: "session.status", + properties: { sessionID: "ses_1", status: { type: "idle" } }, + }, + }) + + const atofPath = path.join(dir, ".nemoflow", "opencode.atof.jsonl") + const atifPath = path.join(dir, ".nemoflow", "opencode.atif.json") + const atof = await fs.readFile(atofPath, "utf8") + const atif = JSON.parse(await fs.readFile(atifPath, "utf8")) + + assert.match(atof, /opencode\.chat\.message/) + assert.match(atof, /opencode\.llm\.request/) + assert.match(atof, /"category":"tool"/) + assert.equal(atif.session_id, "ses_1") + assert.ok(atif.steps.some((event) => event.name === "opencode.session.flush")) + }) + + it("records session lifecycle events, message metadata, errors, and deleted flushes", async () => { + const dir = await makeTempDir() + const server = createServerPlugin({ loadRuntime: async () => createFakeRuntime() }) + const hooks = await server( + { directory: dir }, + { + enabled: true, + atofPath: "./.nemoflow/opencode.atof.jsonl", + atifPath: "./.nemoflow/opencode.atif.json", + logPath: "./.nemoflow/opencode-plugin.log", + }, + ) + + const model = { providerID: "anthropic", modelID: "claude-test" } + await hooks.event?.({ + event: { + id: "evt_created", + type: "session.created", + properties: { + sessionID: "ses_2", + info: { id: "ses_2", agent: "review", model }, + apiKey: "secret", + outputTokens: 8, + }, + }, + }) + await hooks.event?.({ + event: { + id: "evt_updated", + type: "session.updated", + properties: { sessionID: "ses_2", info: { id: "ses_2", agent: "review", model } }, + }, + }) + await hooks["chat.message"]?.( + { + sessionID: "ses_2", + agent: "review", + model, + messageID: "msg_2", + apiKey: "secret", + outputTokens: 3, + }, + { + message: { id: "msg_2", role: "user", agent: "review" }, + parts: [{ id: "part_2", type: "text", text: "summarize this" }], + }, + ) + await hooks["chat.params"]?.( + { + sessionID: "ses_2", + agent: "review", + model, + provider: { source: "config", options: { apiKey: "secret" } }, + message: { id: "msg_2" }, + }, + { maxOutputTokens: 64, temperature: 0 }, + ) + await hooks.event?.({ + event: { + id: "evt_error", + type: "session.error", + properties: { sessionID: "ses_2", error: { message: "provider failed", apiKey: "secret" } }, + }, + }) + await hooks.event?.({ + event: { + id: "evt_deleted", + type: "session.deleted", + properties: { sessionID: "ses_2" }, + }, + }) + + const atofPath = path.join(dir, ".nemoflow", "opencode.atof.jsonl") + const atifPath = path.join(dir, ".nemoflow", "opencode.atif.json") + const events = await readJsonl(atofPath) + const names = events.map((event) => event.name).filter(Boolean) + const message = events.find((event) => event.name === "opencode.chat.message") + const serialized = JSON.stringify(events) + const atif = JSON.parse(await fs.readFile(atifPath, "utf8")) + + assert.ok(names.includes("opencode.session.created")) + assert.ok(names.includes("opencode.session.updated")) + assert.ok(names.includes("opencode.session.error")) + assert.ok(names.includes("opencode.session.deleted")) + assert.equal(message.metadata.sessionID, "ses_2") + assert.equal(message.metadata.agent, "review") + assert.equal(message.metadata.model, "anthropic/claude-test") + assert.match(serialized, /"apiKey":"\[Redacted\]"/) + assert.match(serialized, /"outputTokens":3/) + assert.equal(atif.session_id, "ses_2") + assert.ok(atif.steps.some((event) => event.name === "opencode.session.flush")) + }) + + it("ignores hooks without an OpenCode session identifier", async () => { + const dir = await makeTempDir() + const server = createServerPlugin({ loadRuntime: async () => createFakeRuntime() }) + const hooks = await server({ directory: dir }, { enabled: true }) + + await assert.doesNotReject(async () => { + await hooks["chat.message"]?.({ agent: "build" }, { message: { id: "msg_missing" } }) + await hooks["chat.params"]?.({ agent: "build", message: { id: "msg_missing" } }, {}) + await hooks["tool.execute.before"]?.({ tool: "read", callID: "call_missing" }, { args: { path: "x" } }) + await hooks["tool.execute.after"]?.({ tool: "read", callID: "call_missing" }, { output: "x" }) + }) + await assert.rejects(fs.stat(path.join(dir, ".nemoflow", "opencode.atof.jsonl"))) + }) + + it("stays pass-through when disabled", async () => { + const dir = await makeTempDir() + const server = createServerPlugin({ loadRuntime: async () => createFakeRuntime() }) + const hooks = await server({ directory: dir }, { enabled: false }) + + assert.deepEqual(hooks, {}) + await assert.rejects(fs.stat(path.join(dir, ".nemoflow", "opencode.atof.jsonl"))) + }) + + it("logs once and disables hooks when the runtime cannot load", async () => { + const dir = await makeTempDir() + const server = createServerPlugin({ + loadRuntime: async () => { + throw new Error("missing native binding") + }, + }) + const hooks = await server({ directory: dir }, { enabled: true }) + + assert.deepEqual(hooks, {}) + const log = await fs.readFile(path.join(dir, ".nemoflow", "opencode-plugin.log"), "utf8") + assert.match(log, /pass-through/) + }) +}) diff --git a/opencode-nemoflow-integration-plan.md b/opencode-nemoflow-integration-plan.md new file mode 100644 index 00000000..8d0c79ae --- /dev/null +++ b/opencode-nemoflow-integration-plan.md @@ -0,0 +1,703 @@ +# OpenCode <> NeMo Flow Integration Plan + +Reference OpenCode checkout: `reference_projects/opencode` + +Reference commit: `dcfe4b0d5184cb93dd2232f1461641d6530e1abb` + +## Goal + +The end goal is a proper OpenCode plugin, with no NeMo Flow-maintained patch +against OpenCode. + +The immediate goal is narrower than full NeMo Flow middleware support: + +1. Build a standalone OpenCode observability plugin using OpenCode's existing + public plugin API. +2. Preserve OpenCode's in-agent session, message, model, tool, and error + context. +3. Export NeMo Flow observability data from inside OpenCode, not from a + sidecar/proxy-only view. + +The current NeMo Flow patch should be treated as a prototype and reference +implementation only. It proves where OpenCode needs plugin extension points, +but it is not the target deliverable. + +The target deliverable for the first milestone is: + +1. NeMo Flow ships an OpenCode plugin that uses only OpenCode's public plugin + API. +2. OpenCode does not contain NeMo Flow-specific code, dependencies, flags, or + config schema. +3. Users can enable the integration through normal OpenCode plugin + configuration, without applying patches. + +Future NeMo Flow intercepts and guardrails may require new OpenCode plugin +hooks, but that is a later milestone after the observability plugin is working. + +The key distinction is: + +- Existing OpenCode hooks are enough for a useful observability plugin. +- Existing OpenCode hooks are not enough for full NeMo Flow middleware behavior + such as request intercepts, execution intercepts, conditional guardrails, and + stream intercepts. +- Switchyard or other proxy interception can observe provider traffic, but it + does not own OpenCode's agent context and hierarchy. The OpenCode plugin + should be the source of agent/session structure. + +## Current OpenCode Plugin Surface + +OpenCode server plugins are loaded from `packages/opencode/src/plugin/index.ts`. +They implement the `Hooks` interface from `packages/plugin/src/index.ts`. + +Current useful hooks for the first observability milestone: + +| Hook | Current behavior | Useful for NeMo Flow observability | Gap for future intercepts | +| --- | --- | --- | --- | +| `config(input)` | Notifies plugins after OpenCode config is loaded. | Initialize NeMo Flow runtime/exporters from plugin options and OpenCode config context. | Enough. | +| `event({ event })` | Receives all OpenCode bus events. | Export session lifecycle, message updates, errors, idle events, and final ATIF/ATOF output. | Enough for passive observability only. | +| `chat.message(input, output)` | Called when a user message is created. | Bind session, agent, model, user message, and turn-level metadata. | Useful, but not a full LLM execution boundary. | +| `chat.params(input, output)` | Lets plugins inspect or mutate model params before the AI SDK call. | Capture provider/model/parameter metadata and approximate LLM request start. | Too narrow for full LLM request rewrite; does not wrap execution or stream lifecycle. | +| `chat.headers(input, output)` | Lets plugins add provider request headers. | Optional correlation header injection for external tracing. | Too narrow for content/message/tool/provider rewrites. | +| `tool.execute.before(input, output)` | Called before tool execution with tool name, session, call id, args. | Start a tool span and capture sanitized input. | Cannot wrap the callback, cannot reliably see thrown errors, cannot apply NeMo Flow execution intercepts. | +| `tool.execute.after(input, output)` | Called after successful tool execution. | Finish a successful tool span and capture sanitized output. | Does not run on every error path unless OpenCode manually catches; cannot alter result. | +| `permission.ask(input, output)` | Lets plugins observe or influence permission decisions. | Potential policy correlation. | Not a tool/LLM execution boundary. | +| `command.execute.before(input, output)` | Called before slash command execution. | Optional command observability. | Not core LLM/tool middleware. | +| `shell.env(input, output)` | Lets plugins mutate shell env. | Optional shell correlation. | Not core LLM/tool middleware. | +| `experimental.chat.messages.transform(input, output)` | Mutates model message history before LLM call. | Possible request shaping. | Experimental and message-only; does not cover stream lifecycle or execution intercepts. | +| `experimental.chat.system.transform(input, output)` | Mutates system prompts. | Possible prompt shaping. | Experimental and system-only. | +| `experimental.session.compacting(input, output)` | Compaction hook. | Optional lifecycle correlation. | Not core LLM/tool middleware. | +| `experimental.compaction.autocontinue(input, output)` | Compaction continuation hook. | Optional lifecycle correlation. | Not core LLM/tool middleware. | +| `experimental.text.complete(input, output)` | Text completion hook. | Optional smaller LLM observability. | Separate from main chat stream. | +| `tool.definition` | Contributes plugin-defined tools. | Useful for plugin-provided tools. | Does not wrap built-in or MCP tools. | + +## What NeMo Flow Needs + +NeMo Flow has two different classes of behavior. The first milestone should +focus only on observability. + +| NeMo Flow behavior | Changes real execution? | Required OpenCode support | +| --- | --- | --- | +| Subscribers/exporters | No. Observes emitted runtime events. | Existing `event`, `config`, `chat.message`, `chat.params`, and tool before/after hooks are enough for an MVP. | +| Scoped lifecycle | No direct mutation, but affects event hierarchy. | Existing OpenCode session/message/tool events are enough to build useful hierarchy. | +| Sanitize guardrails | No. Redacts observability payloads only. | Can be applied inside the plugin before exporting. | +| Request intercepts | Yes. Rewrites tool args or LLM request payload. | Later milestone. Needs OpenCode to pass rewritten request/args into the real callback. | +| Execution intercepts | Yes. Wraps, replaces, retries, caches, or short-circuits execution. | Later milestone. Needs an around hook with `next`. | +| Stream execution intercepts | Yes. Wraps async LLM streams. | Later milestone. Needs an around hook for the stream producer. | +| Conditional guardrails | Yes. Blocks execution. | Later milestone. Needs a managed boundary before the real callback. | + +## Observability Plugin MVP + +The first plugin should be a normal OpenCode server plugin distributed like +community plugins. + +It should use only the existing OpenCode plugin API: + +| OpenCode hook | NeMo Flow plugin responsibility | +| --- | --- | +| `config` | Read plugin options, initialize NeMo Flow, set exporter paths, and log disabled/misconfigured state once. | +| `event` | Build session/message/error lifecycle records and flush/export when a session becomes idle or deleted. | +| `chat.message` | Create or update a turn/session record with user message, agent, and selected model metadata. | +| `chat.params` | Capture provider/model/parameter metadata near the LLM request boundary. | +| `tool.execute.before` | Start a tool span using `sessionID`, `callID`, tool name, and sanitized args. | +| `tool.execute.after` | Finish a successful tool span with title/output/metadata. | + +Expected first-milestone output: + +1. Session-level ATIF/ATOF records with OpenCode session IDs. +2. Message and turn metadata with agent and model context where available. +3. Tool spans for successful builtin, MCP, plugin, and task tool calls where + OpenCode emits before/after hooks. +4. Session error records from OpenCode events. +5. A documented limitation for exact LLM stream boundaries and tool error spans + until OpenCode exposes around-style hooks. + +Switchyard can still be useful for provider-level request/response capture, but +it should not replace the OpenCode plugin for agent context. The plugin should +own hierarchy; proxy data can be correlated later if needed. + +## Version Compatibility Strategy + +The NeMo Flow OpenCode plugin should follow the existing OpenCode community +plugin pattern: + +1. Publish or build as a normal npm package with a server plugin entrypoint, + for example `exports["./server"]`. +2. Depend on `@opencode-ai/plugin` and, if needed, `@opencode-ai/sdk` using a + semver range. +3. Declare supported OpenCode versions with `engines.opencode`, for example + `">=1.3.13"` once the minimum tested version is chosen. +4. Do not pin an OpenCode source checkout as part of the plugin runtime. +5. Use CI to test the minimum supported OpenCode version and latest stable + OpenCode. + +Pinned `reference_projects/opencode` and `third_party/opencode` checkouts are +still useful for development and regression testing, but they should not become +the plugin installation model. + +## Future Intercept Hooks + +After the observability plugin works, NeMo Flow can evaluate whether OpenCode +needs new generic plugin hooks for real execution intercepts. + +The likely missing first-party OpenCode hooks are: + +```ts +llm.stream.wrap(input, next) +tool.execute.wrap(input, next) +``` + +The names are placeholders. The important part is the semantics: OpenCode owns +the integration point, but the plugin can wrap the real callback and decide +whether and how to call `next`. + +## Future First-Party Hook Shapes + +### LLM Stream Hook + +```ts +type LlmStreamWrapInput = { + sessionID: string + parentSessionID?: string + agent: string + model: Model + provider: ProviderContext + message: UserMessage + request: { + system: string[] + messages: ModelMessage[] + tools: Record + toolChoice?: "auto" | "required" | "none" + params: { + temperature?: number + topP?: number + topK?: number + maxOutputTokens?: number + options: Record + } + headers: Record + } +} + +type LlmStreamWrapNext = ( + input: LlmStreamWrapInput, +) => AsyncIterable | Promise> + +type LlmStreamWrapHook = ( + input: LlmStreamWrapInput, + next: LlmStreamWrapNext, +) => AsyncIterable | Promise> +``` + +Expected semantics: + +1. OpenCode builds the final request object before calling the AI SDK. +2. OpenCode calls plugins as a nested chain. +3. The NeMo Flow plugin serializes the request, runs NeMo Flow + `llm_stream_execute`, applies request intercept results, then calls `next`. +4. OpenCode sends the final rewritten request to the provider. +5. Chunks flow back through the wrapper so NeMo Flow can emit stream start, + chunk, end, and error events. + +### Tool Execute Hook + +```ts +type ToolExecuteWrapInput = { + tool: string + sessionID: string + callID: string + args: unknown + source: "builtin" | "mcp" | "task" | "plugin" +} + +type ToolExecuteWrapNext = (input: ToolExecuteWrapInput) => Promise + +type ToolExecuteWrapHook = ( + input: ToolExecuteWrapInput, + next: ToolExecuteWrapNext, +) => Promise +``` + +Expected semantics: + +1. OpenCode validates tool input and constructs the tool execution context. +2. OpenCode calls the wrapper chain. +3. The NeMo Flow plugin runs `tool_call_execute`. +4. NeMo Flow request intercepts can rewrite `input.args`. +5. NeMo Flow execution intercepts can call `next`, skip `next`, retry, cache, or + replace the result. +6. OpenCode receives the final result and continues its normal message update + path. + +## Hook Composition + +OpenCode currently runs hooks sequentially in plugin load order. Around hooks +should preserve that simplicity: + +```ts +function composeWrapHooks(hooks, finalNext) { + return hooks.reduceRight( + (next, hook) => (input) => hook(input, next), + finalNext, + ) +} +``` + +No priority field is required for the first version. Load order is enough and +matches the rest of the plugin system. A priority field can be added later only +if OpenCode wants deterministic ordering independent of config order. + +## Implementation Breakdown + +### Phase 0: Use The Existing Patch As Reference Only + +The current NeMo Flow patch is useful for learning and validation, but it +should not be the production integration strategy. + +Use it to answer these questions: + +1. Which OpenCode events are needed for session/message hierarchy? +2. Which existing hooks provide agent/model/tool metadata? +3. Which ATOF/ATIF output can be produced without patching OpenCode? +4. Which exact behaviors remain impossible without around-style hooks? + +Do not add more NeMo Flow-specific code to OpenCode as the long-term path. + +### Shared Smoke Test Setup + +Use this setup for every phase demo. The goal is to show the integration as an +end user would run it, not as a patched OpenCode checkout. + +Assumptions: + +1. Node.js 20 or newer is installed. +2. OpenCode can reach at least one configured model provider. +3. `jq` is installed for checking JSON demo output. +4. The NeMo Flow OpenCode plugin package has been built or published. +5. The final package name is still open. The examples below use + `@nvidia/nemoflow-opencode-plugin` as a placeholder. + +Common commands: + +```bash +git clone https://github.com/NVIDIA/NeMo-Flow.git +cd NeMo-Flow + +npm install -g opencode-ai@latest +opencode --version + +export NEMO_FLOW_OPENCODE_PLUGIN="@nvidia/nemoflow-opencode-plugin" +export NEMO_FLOW_DEMO_DIR="$PWD/tmp/opencode-nemoflow-demo" + +rm -rf "$NEMO_FLOW_DEMO_DIR" +mkdir -p "$NEMO_FLOW_DEMO_DIR/.nemoflow" +cd "$NEMO_FLOW_DEMO_DIR" + +cat > opencode.json < opencode.disabled.json +mv opencode.disabled.json opencode.json +rm -f ./.nemoflow/* + +opencode run \ + --title "nemo-flow phase 2 disabled smoke" \ + "Reply with exactly: plugin disabled smoke." + +ls -la ./.nemoflow +mv opencode.enabled.json opencode.json +``` + +Expected disabled output: + +1. OpenCode still completes the run. +2. The NeMo Flow output files are absent or empty. + +Run the init-failure path only if the plugin exposes a demo failure switch: + +```bash +rm -f ./.nemoflow/* +NEMO_FLOW_OPENCODE_FORCE_INIT_FAILURE=1 opencode run \ + --title "nemo-flow phase 2 init failure smoke" \ + "Reply with exactly: init failure smoke." + +grep -i "failed\\|disabled\\|pass-through" ./.nemoflow/opencode-plugin.log +``` + +Expected failure output: + +1. OpenCode still completes the run. +2. The plugin log records one clear pass-through message. +3. No partial or corrupt ATIF/ATOF output is written. + +### Phase 3: Retire The Patch For Observability + +Once the observability plugin works: + +1. Stop treating the OpenCode patch as the observability integration path. +2. Remove OpenCode-specific NeMo Flow flags, config schema, and internal plugin + code from the OpenCode tree. +3. Update NeMo Flow docs to explain how to install and configure the OpenCode + plugin. +4. Keep the old patch only as temporary reference material if it is still useful + for the later intercept investigation. + +#### Smoke Test Guide + +This demo proves the third promised feature: the observability integration works +with a stock OpenCode install and does not depend on the NeMo Flow OpenCode +patch. + +Run from a fresh directory outside the NeMo Flow repository: + +```bash +export CLEAN_DEMO_DIR="$(mktemp -d)" +cd "$CLEAN_DEMO_DIR" +mkdir -p .nemoflow + +npm install -g opencode-ai@latest +opencode --version | tee ./.nemoflow/opencode-version.txt +which opencode | tee ./.nemoflow/opencode-path.txt + +cat > opencode.json < Date: Fri, 8 May 2026 11:54:48 -0400 Subject: [PATCH 2/7] opencode obsv plugin Signed-off-by: Binfeng Xu --- .gitignore | 2 + docs/index.md | 1 + docs/integrate-frameworks/about.md | 1 + docs/integrate-frameworks/opencode.md | 271 ++++ integrations/opencode-plugin/README.md | 76 ++ .../opencode-plugin/package-lock.json | 1185 +++++++++++++++++ integrations/opencode-plugin/package.json | 55 + integrations/opencode-plugin/server.js | 509 +++++++ .../opencode-plugin/test/server.test.mjs | 346 +++++ opencode-nemoflow-integration-plan.md | 703 ++++++++++ 10 files changed, 3149 insertions(+) create mode 100644 docs/integrate-frameworks/opencode.md create mode 100644 integrations/opencode-plugin/README.md create mode 100644 integrations/opencode-plugin/package-lock.json create mode 100644 integrations/opencode-plugin/package.json create mode 100644 integrations/opencode-plugin/server.js create mode 100644 integrations/opencode-plugin/test/server.test.mjs create mode 100644 opencode-nemoflow-integration-plan.md diff --git a/.gitignore b/.gitignore index eba83c4f..e8cc9d3d 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,5 @@ CHANGELOG.md crates/wasm/coverage/ .scannerwork/ + +**/.nemoflow/ \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 8e05fcd8..6b53133c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -165,6 +165,7 @@ Advanced Guide: Handle Non-Serializable Data Advanced Guide: Provider Codecs Advanced Guide: Provider Response Codecs +OpenCode Plugin Code Examples ``` diff --git a/docs/integrate-frameworks/about.md b/docs/integrate-frameworks/about.md index a12a1b77..cd8361bf 100644 --- a/docs/integrate-frameworks/about.md +++ b/docs/integrate-frameworks/about.md @@ -41,6 +41,7 @@ Use these guide links to move from the overview into task-specific instructions. - [Advanced Guide: Using Codecs](using-codecs.md) explains typed value codecs for framework-facing wrappers. - [Advanced Guide: Provider Codecs](provider-codecs.md) explains provider request and response codecs for normalized middleware and event annotations. - [Advanced Guide: Provider Response Codecs](provider-response-codecs.md) focuses on response-only annotations for subscribers and exporters. +- [OpenCode Plugin](opencode.md) explains how to install and configure the standalone OpenCode observability plugin. - [Code Examples](code-examples.md) collects fallback APIs, mark events, and repository patch workflow examples. Start by identifying the framework's stable tool and LLM boundaries. Prefer diff --git a/docs/integrate-frameworks/opencode.md b/docs/integrate-frameworks/opencode.md new file mode 100644 index 00000000..9d7fe200 --- /dev/null +++ b/docs/integrate-frameworks/opencode.md @@ -0,0 +1,271 @@ + + +# OpenCode Plugin + +NeMo Flow integrates with OpenCode through a standalone server plugin. The +plugin uses OpenCode's public plugin hooks and does not require a patched +OpenCode checkout. + +Use this plugin when you want NeMo Flow observability for OpenCode sessions, +messages, LLM request metadata, successful tool calls, and session errors. + +## What You Build + +You will configure stock OpenCode to load the NeMo Flow plugin in the +background. After that, you can use OpenCode normally through the interactive +interface or `opencode run`. The plugin observes OpenCode hooks and writes +NeMo Flow ATOF and ATIF files under the OpenCode project directory. + +```{mermaid} +flowchart LR + User[Developer] + OpenCode[Stock OpenCode] + Plugin[NeMo Flow
OpenCode plugin] + Runtime[NeMo Flow
Node.js binding] + ATOF[(ATOF JSONL)] + ATIF[(ATIF JSON)] + + User -->|uses normally| OpenCode + OpenCode -->|public plugin hooks| Plugin + Plugin -->|scopes, marks,
tool lifecycle| Runtime + Runtime -->|append events| ATOF + Runtime -->|export on idle
or deleted session| ATIF + + class User blue-lightest; + class OpenCode green-lightest; + class Plugin purple-lightest; + class Runtime green-light; + class ATOF yellow-lightest; + class ATIF yellow-lightest; +``` + +The plugin is passive. It records observability output but does not rewrite +prompts, tool arguments, model requests, or OpenCode execution behavior. + +## Install + +Build the NeMo Flow Node.js binding before loading the plugin from a source +checkout. `crates/node` is under the NeMo Flow repository root: + +```bash +export NEMO_FLOW_REPO=/absolute/path/to/NeMo-Flow +cd "$NEMO_FLOW_REPO/crates/node" +npm install +npm run build +``` + +For local development, install or use stock OpenCode and point `opencode.json` +at the plugin directory: + +```bash +npm install -g opencode-ai@latest +opencode --version +``` + +When the plugin package is published, use +`@nvidia/nemoflow-opencode-plugin` in the OpenCode config instead of the local +file URL. + +## Configure OpenCode + +Create or update `opencode.json` in the OpenCode project directory: + +```json +{ + "plugin": [ + [ + "file:///absolute/path/to/NeMo-Flow/integrations/opencode-plugin", + { + "enabled": true, + "atofPath": "./.nemoflow/opencode.atof.jsonl", + "atifPath": "./.nemoflow/opencode.atif.json", + "logPath": "./.nemoflow/opencode-plugin.log" + } + ] + ] +} +``` + +The paths are resolved relative to the OpenCode project directory. If +`nemo-flow-node` is missing or cannot initialize, the plugin logs one warning +and returns no hooks, so OpenCode continues in pass-through mode. + +## Run the Demo + +Use this demo when you want to show the integration end to end. It uses a +source checkout plugin path because the package is not published yet. + +```bash +export NEMO_FLOW_REPO=/absolute/path/to/NeMo-Flow +export NEMO_FLOW_DEMO_DIR="$NEMO_FLOW_REPO/tmp/opencode-nemoflow-demo" + +rm -rf "$NEMO_FLOW_DEMO_DIR" +mkdir -p "$NEMO_FLOW_DEMO_DIR/.nemoflow" +cd "$NEMO_FLOW_DEMO_DIR" + +cat > opencode.json <>OC: Start OpenCode in a project + OC->>Plug: config(input) + Plug->>Files: Write plugin diagnostics + Dev->>OC: Send a prompt or run a task + OC->>Plug: chat.message and chat.params + Plug->>NF: Emit session and LLM request marks + NF->>Files: Append ATOF JSONL + OC->>Plug: tool.execute.before and after + Plug->>NF: Open and close tool lifecycle records + NF->>Files: Append ATOF JSONL + OC->>Plug: session.status idle or session.deleted + Plug->>NF: Flush session trajectory + NF->>Files: Write ATIF JSON +``` + +## Pass-Through Checks + +The plugin should not change OpenCode behavior when observability is disabled +or when the NeMo Flow runtime is unavailable. + +Disable the plugin: + +```bash +cp opencode.json opencode.enabled.json +jq '(.plugin[0][1].enabled) = false' opencode.json > opencode.disabled.json +mv opencode.disabled.json opencode.json +rm -f ./.nemoflow/opencode.* + +opencode run --title "nemo-flow disabled smoke" \ + "Reply with exactly: plugin disabled smoke." + +test ! -s ./.nemoflow/opencode.atof.jsonl +test ! -s ./.nemoflow/opencode.atif.json +mv opencode.enabled.json opencode.json +``` + +Force runtime initialization failure: + +```bash +rm -f ./.nemoflow/opencode.* + +NEMO_FLOW_OPENCODE_FORCE_INIT_FAILURE=1 opencode run \ + --title "nemo-flow init failure smoke" \ + "Reply with exactly: init failure smoke." + +grep -i "pass-through" ./.nemoflow/opencode-plugin.log +test ! -s ./.nemoflow/opencode.atof.jsonl +``` + +## Demo Video Script + +Use this storyboard to record a short walkthrough. + +| Shot | Show | Narration | +|---|---|---| +| 1 | `opencode.json` with the plugin file URL | Stock OpenCode loads the NeMo Flow plugin through normal plugin config. | +| 2 | `opencode debug info` output | OpenCode sees the plugin without applying an OpenCode patch. | +| 3 | `opencode run` or the interactive OpenCode UI | The developer uses OpenCode normally. | +| 4 | `ls -la .nemoflow` | The plugin writes observability files in the background. | +| 5 | `grep` against `opencode.atof.jsonl` | ATOF contains session, message, LLM request, and tool lifecycle events. | +| 6 | `jq` against `opencode.atif.json` | ATIF contains the exported session trajectory. | +| 7 | Disabled or forced-failure smoke | OpenCode still runs when the plugin is disabled or pass-through. | + +Keep the recording focused on the user-visible contract: install the plugin, +use OpenCode normally, and inspect `.nemoflow` output after the session. + +## Limits + +The current OpenCode plugin API is enough for passive observability. It is not +enough for NeMo Flow request intercepts, execution intercepts, conditional +blocking, or complete tool error spans because OpenCode does not yet expose +around-style LLM or tool hooks. Future work should add generic OpenCode plugin +hooks upstream before enabling those behaviors. diff --git a/integrations/opencode-plugin/README.md b/integrations/opencode-plugin/README.md new file mode 100644 index 00000000..1eea1333 --- /dev/null +++ b/integrations/opencode-plugin/README.md @@ -0,0 +1,76 @@ + + +# NeMo Flow OpenCode Plugin + +This package is a standalone OpenCode server plugin for NeMo Flow observability. +It uses OpenCode's public plugin API and does not require patching OpenCode. + +For the illustrated setup guide and demo recording script, see +`docs/integrate-frameworks/opencode.md` in the NeMo Flow source checkout. + +## Configuration + +Use the plugin from an OpenCode config file. From a NeMo Flow source checkout, +use a file URL: + +```json +{ + "plugin": [ + [ + "file:///absolute/path/to/NeMo-Flow/integrations/opencode-plugin", + { + "enabled": true, + "atofPath": "./.nemoflow/opencode.atof.jsonl", + "atifPath": "./.nemoflow/opencode.atif.json", + "logPath": "./.nemoflow/opencode-plugin.log" + } + ] + ] +} +``` + +When this package is published, replace the file URL with the package name: + +```json +{ + "plugin": [ + [ + "@nvidia/nemoflow-opencode-plugin", + { + "enabled": true, + "atofPath": "./.nemoflow/opencode.atof.jsonl", + "atifPath": "./.nemoflow/opencode.atif.json", + "logPath": "./.nemoflow/opencode-plugin.log" + } + ] + ] +} +``` + +The package loads `nemo-flow-node` dynamically. If the native Node binding is +missing or cannot initialize, the plugin logs one pass-through warning and does +not change OpenCode behavior. + +## Compatibility + +The plugin declares support for OpenCode `>=1.14.40`. It uses the public +OpenCode server plugin hooks that are available in `@opencode-ai/plugin` +`1.14.40` and were verified against OpenCode `1.14.41`. + +## Output + +- `atofPath` receives raw NeMo Flow ATOF JSONL events for OpenCode session, + message, LLM request metadata, error, and successful tool lifecycle records. +- `atifPath` receives a session trajectory when OpenCode reports a session as + idle or deleted. +- `logPath` receives JSONL plugin diagnostics. + +## Current Limitations + +This plugin uses only existing OpenCode hooks. OpenCode does not yet expose an +around-style LLM stream hook or tool execution hook, so the plugin cannot record +exact LLM stream duration, tool error spans for every failure path, request +intercepts, execution intercepts, or conditional guardrail blocking. diff --git a/integrations/opencode-plugin/package-lock.json b/integrations/opencode-plugin/package-lock.json new file mode 100644 index 00000000..eba44d2e --- /dev/null +++ b/integrations/opencode-plugin/package-lock.json @@ -0,0 +1,1185 @@ +{ + "name": "@nvidia/nemoflow-opencode-plugin", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@nvidia/nemoflow-opencode-plugin", + "version": "0.2.0", + "license": "Apache-2.0", + "dependencies": { + "nemo-flow-node": "file:../../crates/node" + }, + "devDependencies": { + "@opencode-ai/plugin": "^1.14.40" + }, + "engines": { + "node": ">=20.0.0", + "opencode": ">=1.14.40" + }, + "peerDependencies": { + "@opencode-ai/plugin": ">=1.14.40" + } + }, + "../../crates/node": { + "name": "nemo-flow-node", + "version": "0.2.0", + "license": "Apache-2.0", + "devDependencies": { + "@napi-rs/cli": "^2", + "c8": "^11.0.0", + "prettier": "^3.8.2", + "typedoc": "^0.28.0", + "typescript": "^5.8.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "../../crates/node/node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "../../crates/node/node_modules/@gerrit0/mini-shiki": { + "version": "3.23.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-oniguruma": "^3.23.0", + "@shikijs/langs": "^3.23.0", + "@shikijs/themes": "^3.23.0", + "@shikijs/types": "^3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "../../crates/node/node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "../../crates/node/node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "../../crates/node/node_modules/@napi-rs/cli": { + "version": "2.18.4", + "dev": true, + "license": "MIT", + "bin": { + "napi": "scripts/index.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "../../crates/node/node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "../../crates/node/node_modules/@shikijs/langs": { + "version": "3.23.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "../../crates/node/node_modules/@shikijs/themes": { + "version": "3.23.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "../../crates/node/node_modules/@shikijs/types": { + "version": "3.23.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "../../crates/node/node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/@types/hast": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "../../crates/node/node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/@types/unist": { + "version": "3.0.3", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "../../crates/node/node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "../../crates/node/node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "../../crates/node/node_modules/brace-expansion": { + "version": "5.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "../../crates/node/node_modules/c8": { + "version": "11.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^8.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": "20 || >=22" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, + "../../crates/node/node_modules/cliui": { + "version": "8.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "../../crates/node/node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "../../crates/node/node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "../../crates/node/node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/entities": { + "version": "4.5.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "../../crates/node/node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "../../crates/node/node_modules/find-up": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "../../crates/node/node_modules/foreground-child": { + "version": "3.3.1", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "../../crates/node/node_modules/get-caller-file": { + "version": "2.0.5", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "../../crates/node/node_modules/glob": { + "version": "13.0.6", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "../../crates/node/node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/html-escaper": { + "version": "2.0.2", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "../../crates/node/node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/istanbul-lib-report": { + "version": "3.0.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "../../crates/node/node_modules/istanbul-reports": { + "version": "3.2.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/linkify-it": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "../../crates/node/node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "../../crates/node/node_modules/lru-cache": { + "version": "11.2.7", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "../../crates/node/node_modules/lunr": { + "version": "2.3.9", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/make-dir": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "../../crates/node/node_modules/markdown-it": { + "version": "14.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "../../crates/node/node_modules/mdurl": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/minimatch": { + "version": "10.2.5", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "../../crates/node/node_modules/minipass": { + "version": "7.1.3", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "../../crates/node/node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "../../crates/node/node_modules/p-locate": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "../../crates/node/node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/path-scurry": { + "version": "2.0.2", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "../../crates/node/node_modules/prettier": { + "version": "3.8.2", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "../../crates/node/node_modules/punycode.js": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "../../crates/node/node_modules/require-directory": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "../../crates/node/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "../../crates/node/node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "../../crates/node/node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/test-exclude": { + "version": "8.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "../../crates/node/node_modules/typedoc": { + "version": "0.28.19", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@gerrit0/mini-shiki": "^3.23.0", + "lunr": "^2.3.9", + "markdown-it": "^14.1.1", + "minimatch": "^10.2.5", + "yaml": "^2.8.3" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 18", + "pnpm": ">= 10" + }, + "peerDependencies": { + "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" + } + }, + "../../crates/node/node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "../../crates/node/node_modules/uc.micro": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/v8-to-istanbul": { + "version": "9.3.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "../../crates/node/node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "../../crates/node/node_modules/wrap-ansi": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "../../crates/node/node_modules/y18n": { + "version": "5.0.8", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "../../crates/node/node_modules/yaml": { + "version": "2.8.3", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "../../crates/node/node_modules/yargs": { + "version": "17.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "../../crates/node/node_modules/yargs-parser": { + "version": "21.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "../../crates/node/node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.14.41", + "dev": true, + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.14.41", + "effect": "4.0.0-beta.59", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.2.2", + "@opentui/solid": ">=0.2.2" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.14.41", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.59", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.6.0", + "find-my-way-ts": "^0.1.6", + "ini": "^6.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^1.11.9", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^13.0.0", + "yaml": "^2.8.3" + } + }, + "node_modules/fast-check": { + "version": "4.7.0", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "dev": true, + "license": "MIT" + }, + "node_modules/ini": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/msgpackr": { + "version": "1.11.12", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/multipasta": { + "version": "0.2.7", + "dev": true, + "license": "MIT" + }, + "node_modules/nemo-flow-node": { + "resolved": "../../crates/node", + "link": true + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pure-rand": { + "version": "8.4.0", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/toml": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/uuid": { + "version": "13.0.2", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yaml": { + "version": "2.8.4", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/integrations/opencode-plugin/package.json b/integrations/opencode-plugin/package.json new file mode 100644 index 00000000..e0281b3f --- /dev/null +++ b/integrations/opencode-plugin/package.json @@ -0,0 +1,55 @@ +{ + "name": "@nvidia/nemoflow-opencode-plugin", + "version": "0.2.0", + "description": "OpenCode server plugin that exports NeMo Flow observability data.", + "type": "module", + "main": "./server.js", + "exports": { + ".": { + "default": "./server.js", + "import": "./server.js" + }, + "./server": { + "default": "./server.js", + "import": "./server.js" + } + }, + "files": [ + "README.md", + "server.js" + ], + "scripts": { + "test": "node --test test/*.mjs" + }, + "keywords": [ + "opencode", + "nemo-flow", + "observability", + "atif", + "atof", + "plugin" + ], + "homepage": "https://github.com/NVIDIA/NeMo-Flow#readme", + "bugs": { + "url": "https://github.com/NVIDIA/NeMo-Flow/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NVIDIA/NeMo-Flow.git", + "directory": "integrations/opencode-plugin" + }, + "license": "Apache-2.0", + "engines": { + "node": ">=20.0.0", + "opencode": ">=1.14.40" + }, + "dependencies": { + "nemo-flow-node": "file:../../crates/node" + }, + "peerDependencies": { + "@opencode-ai/plugin": ">=1.14.40" + }, + "devDependencies": { + "@opencode-ai/plugin": "^1.14.40" + } +} diff --git a/integrations/opencode-plugin/server.js b/integrations/opencode-plugin/server.js new file mode 100644 index 00000000..ea4c77db --- /dev/null +++ b/integrations/opencode-plugin/server.js @@ -0,0 +1,509 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fsSync from "node:fs" +import fs from "node:fs/promises" +import path from "node:path" + +const PLUGIN_ID = "@nvidia/nemoflow-opencode-plugin" +const AGENT_VERSION = "opencode-plugin-0.2.0" +const RELEVANT_EVENTS = new Set([ + "session.created", + "session.updated", + "session.deleted", + "session.error", + "session.status", + "session.idle", + "message.updated", + "message.removed", + "message.part.updated", + "message.part.delta", + "message.part.removed", +]) + +function createLogger(logPath) { + const seen = new Set() + + async function write(level, message, extra) { + const record = { + timestamp: new Date().toISOString(), + level, + plugin: PLUGIN_ID, + message, + ...(extra === undefined ? {} : { extra: toJsonSafe(extra) }), + } + const line = JSON.stringify(record) + "\n" + if (logPath) { + await ensureParentDir(logPath) + await fs.appendFile(logPath, line) + return + } + const text = `[${PLUGIN_ID}] ${message}` + if (level === "error") console.error(text, extra ?? "") + else if (level === "warn") console.warn(text, extra ?? "") + else console.info(text, extra ?? "") + } + + return { + info: (message, extra) => write("info", message, extra), + warn: (message, extra) => write("warn", message, extra), + error: (message, extra) => write("error", message, extra), + warnOnce: (key, message, extra) => { + if (seen.has(key)) return Promise.resolve() + seen.add(key) + return write("warn", message, extra) + }, + } +} + +async function ensureParentDir(filePath) { + await fs.mkdir(path.dirname(filePath), { recursive: true }) +} + +function resolveOutputPath(baseDir, value) { + if (typeof value !== "string" || value.trim() === "") return undefined + if (path.isAbsolute(value)) return value + return path.resolve(baseDir, value) +} + +function normalizeOptions(input, options = {}) { + const baseDir = input?.directory ?? process.cwd() + return { + enabled: options.enabled !== false, + atofPath: resolveOutputPath(baseDir, options.atofPath ?? "./.nemoflow/opencode.atof.jsonl"), + atifPath: resolveOutputPath(baseDir, options.atifPath ?? "./.nemoflow/opencode.atif.json"), + logPath: resolveOutputPath(baseDir, options.logPath ?? "./.nemoflow/opencode-plugin.log"), + } +} + +function toJsonSafe(value) { + if (value === undefined) return null + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack, + } + } + + const seen = new WeakSet() + try { + return JSON.parse( + JSON.stringify(value, (key, nested) => { + if (/^(api[-_]?key|authorization|password|secret|access[-_]?token|refresh[-_]?token|id[-_]?token|token)$/i.test(key)) { + return "[Redacted]" + } + if (typeof nested === "bigint") return nested.toString() + if (typeof nested === "function") return `[Function ${nested.name || "anonymous"}]` + if (nested instanceof Error) return toJsonSafe(nested) + if (nested && typeof nested === "object") { + if (seen.has(nested)) return "[Circular]" + seen.add(nested) + } + return nested + }), + ) + } catch { + return null + } +} + +function modelName(model) { + if (!model) return undefined + const provider = model.providerID ?? model.provider?.id + const id = model.modelID ?? model.id + if (provider && id) return `${provider}/${id}` + if (id) return String(id) + return undefined +} + +function agentName(input, fallback = "opencode") { + if (typeof input?.agent === "string" && input.agent) return input.agent + if (typeof input?.message?.agent === "string" && input.message.agent) return input.message.agent + if (typeof input?.info?.agent === "string" && input.info.agent) return input.info.agent + return fallback +} + +function eventSessionID(event) { + const props = event?.properties + return props?.sessionID ?? props?.info?.id +} + +function inputSessionMetadata(sessionID, state) { + return { + source: "opencode", + sessionID, + agent: state.agent, + model: state.model, + } +} + +function eventMetadata(session, extra = {}) { + return { + agent: session?.agent, + model: session?.model, + ...extra, + } +} + +function shouldFlushEvent(event) { + if (!event) return false + if (event.type === "session.deleted" || event.type === "session.idle") return true + if (event.type !== "session.status") return false + return event.properties?.status?.type === "idle" +} + +function createNemoFlowAdapter(lib, options, logger) { + const sessions = new Map() + const recentFlushes = new Map() + const trajectories = [] + let atofSubscriberName + let atofDeregisterTimer + let closed = false + + function registerAtOfJsonlExporter() { + if (atofDeregisterTimer) { + clearTimeout(atofDeregisterTimer) + atofDeregisterTimer = undefined + } + if (atofSubscriberName || !options.atofPath) return + fsSync.mkdirSync(path.dirname(options.atofPath), { recursive: true }) + atofSubscriberName = `${PLUGIN_ID}:atof:${process.pid}:${Date.now()}` + lib.registerSubscriber(atofSubscriberName, (event) => { + fsSync.appendFileSync(options.atofPath, JSON.stringify(event) + "\n") + }) + void logger.info("registered ATOF JSONL exporter", { path: options.atofPath }) + } + + function deregisterAtOfJsonlExporter() { + if (atofDeregisterTimer) { + clearTimeout(atofDeregisterTimer) + atofDeregisterTimer = undefined + } + if (!atofSubscriberName) return + try { + lib.deregisterSubscriber(atofSubscriberName) + } catch (error) { + void logger.warnOnce("atof-deregister", "failed to deregister ATOF JSONL exporter", error) + } finally { + atofSubscriberName = undefined + } + } + + function scheduleAtOfJsonlExporterDeregister() { + if (!atofSubscriberName || atofDeregisterTimer) return + atofDeregisterTimer = setTimeout(() => { + deregisterAtOfJsonlExporter() + }, 250) + } + + function withStack(session, callback) { + if (!session.stack || typeof lib.setThreadScopeStack !== "function") return callback() + const previous = typeof lib.currentScopeStack === "function" ? lib.currentScopeStack() : undefined + lib.setThreadScopeStack(session.stack) + try { + return callback() + } finally { + if (previous) lib.setThreadScopeStack(previous) + } + } + + function ensureSession(sessionID, metadata = {}) { + if (!sessionID) return undefined + registerAtOfJsonlExporter() + + let session = sessions.get(sessionID) + if (session) { + if (metadata.agent) session.agent = metadata.agent + if (metadata.model) session.model = metadata.model + return session + } + + session = { + id: sessionID, + agent: metadata.agent ?? "opencode", + model: metadata.model, + stack: typeof lib.createScopeStack === "function" ? lib.createScopeStack() : undefined, + scope: undefined, + exporter: undefined, + exporterName: `${PLUGIN_ID}:atif:${sessionID}:${Date.now()}`, + pendingTools: new Map(), + } + + session.exporter = new lib.AtifExporter(session.id, session.agent, AGENT_VERSION, session.model ?? null) + session.exporter.register(session.exporterName) + session.scope = withStack(session, () => + lib.pushScope( + "opencode.session", + lib.ScopeType?.Agent ?? 0, + null, + null, + { sessionID }, + inputSessionMetadata(sessionID, session), + { sessionID, source: "opencode" }, + ), + ) + sessions.set(sessionID, session) + emitMark(session, "opencode.session.observed", { + sessionID, + agent: session.agent, + model: session.model, + }) + return session + } + + function emitMark(session, name, data, metadata = {}) { + if (!session?.scope) return + lib.event( + name, + session.scope, + toJsonSafe(data), + { + source: "opencode", + sessionID: session.id, + ...toJsonSafe(metadata), + }, + null, + ) + } + + function writeAtifFile() { + if (!options.atifPath) return + const payload = trajectories.length === 1 ? trajectories[0] : { trajectories } + fsSync.mkdirSync(path.dirname(options.atifPath), { recursive: true }) + fsSync.writeFileSync(options.atifPath, JSON.stringify(payload, null, 2)) + } + + function flushSession(sessionID, reason) { + const session = sessions.get(sessionID) + if (!session) return + recentFlushes.set(sessionID, Date.now()) + emitMark(session, "opencode.session.flush", { sessionID, reason }) + for (const [key, tool] of session.pendingTools) { + try { + lib.toolCallEnd( + tool.handle, + { status: "unknown", reason: "session flushed before tool.execute.after" }, + null, + { source: "opencode", sessionID, callID: tool.callID }, + ) + } catch (error) { + void logger.warnOnce(`tool-close:${key}`, "failed to close pending OpenCode tool span", error) + } + } + session.pendingTools.clear() + + if (session.scope) { + try { + withStack(session, () => lib.popScope(session.scope, { sessionID, reason }, null)) + } catch (error) { + void logger.warnOnce(`scope-pop:${sessionID}`, "failed to close OpenCode session scope", error) + } + } + + try { + trajectories.push(JSON.parse(session.exporter.exportJson())) + writeAtifFile() + } catch (error) { + void logger.warnOnce(`atif-export:${sessionID}`, "failed to export ATIF trajectory", error) + } + + try { + session.exporter.deregister(session.exporterName) + } catch (error) { + void logger.warnOnce(`atif-deregister:${sessionID}`, "failed to deregister ATIF exporter", error) + } + sessions.delete(sessionID) + if (sessions.size === 0) scheduleAtOfJsonlExporterDeregister() + } + + return { + async recordConfig(config) { + if (closed) return + await logger.info("observed OpenCode config", { + model: config?.model, + agents: config?.agent ? Object.keys(config.agent) : undefined, + }) + }, + + async recordEvent(event) { + if (closed || !RELEVANT_EVENTS.has(event?.type)) return + const sessionID = eventSessionID(event) + if (!sessionID) return + const recentFlushAt = recentFlushes.get(sessionID) + if (shouldFlushEvent(event) && recentFlushAt && Date.now() - recentFlushAt < 2000) return + const props = event.properties ?? {} + const session = ensureSession(sessionID, { + agent: agentName(props.info, undefined), + model: modelName(props.info?.model), + }) + emitMark( + session, + `opencode.${event.type}`, + { + id: event.id, + type: event.type, + properties: props, + }, + eventMetadata(session, { eventType: event.type }), + ) + if (shouldFlushEvent(event)) { + flushSession(sessionID, event.type) + } + }, + + async recordChatMessage(input, output) { + if (closed) return + const session = ensureSession(input.sessionID, { + agent: agentName(input), + model: modelName(input.model ?? output?.message?.model), + }) + if (!session) return + emitMark( + session, + "opencode.chat.message", + { + input, + message: output?.message, + parts: output?.parts, + }, + eventMetadata(session, { messageID: input.messageID ?? output?.message?.id }), + ) + }, + + async recordChatParams(input, output) { + if (closed) return + const session = ensureSession(input.sessionID, { + agent: agentName(input), + model: modelName(input.model), + }) + if (!session) return + emitMark( + session, + "opencode.llm.request", + { + sessionID: input.sessionID, + agent: input.agent, + provider: input.provider, + model: input.model, + message: input.message, + params: output, + limitation: "OpenCode Phase 1 hooks expose request metadata but not exact stream completion.", + }, + eventMetadata(session, { messageID: input.message?.id }), + ) + }, + + async recordToolBefore(input, output) { + if (closed) return + const session = ensureSession(input.sessionID) + if (!session) return + const args = toJsonSafe(output?.args) + const handle = lib.toolCall( + input.tool, + args, + session.scope, + null, + { sessionID: input.sessionID, callID: input.callID }, + { source: "opencode", sessionID: input.sessionID, callID: input.callID }, + input.callID, + null, + ) + session.pendingTools.set(input.callID, { handle, callID: input.callID, tool: input.tool, args }) + }, + + async recordToolAfter(input, output) { + if (closed) return + const session = ensureSession(input.sessionID) + if (!session) return + let pending = session.pendingTools.get(input.callID) + if (!pending) { + const args = toJsonSafe(input.args) + const handle = lib.toolCall( + input.tool, + args, + session.scope, + null, + { sessionID: input.sessionID, callID: input.callID }, + { source: "opencode", sessionID: input.sessionID, callID: input.callID, recovered: true }, + input.callID, + null, + ) + pending = { handle, callID: input.callID, tool: input.tool, args } + } + lib.toolCallEnd( + pending.handle, + toJsonSafe({ + title: output?.title, + output: output?.output, + metadata: output?.metadata, + }), + null, + { source: "opencode", sessionID: input.sessionID, callID: input.callID }, + null, + ) + session.pendingTools.delete(input.callID) + }, + + async close() { + closed = true + for (const sessionID of [...sessions.keys()]) { + flushSession(sessionID, "plugin-close") + } + deregisterAtOfJsonlExporter() + }, + } +} + +async function loadDefaultRuntime() { + if (process.env.NEMO_FLOW_OPENCODE_FORCE_INIT_FAILURE === "1") { + throw new Error("forced initialization failure") + } + const mod = await import("nemo-flow-node") + return mod.default ?? mod +} + +export function createServerPlugin({ loadRuntime = loadDefaultRuntime } = {}) { + return async function server(input, options) { + const normalized = normalizeOptions(input, options) + const logger = createLogger(normalized.logPath) + + if (!normalized.enabled) { + await logger.warnOnce("disabled", "NeMo Flow OpenCode plugin disabled by configuration") + return {} + } + + let adapter + try { + const lib = await loadRuntime() + adapter = createNemoFlowAdapter(lib, normalized, logger) + await logger.info("initialized NeMo Flow OpenCode plugin", { + atofPath: normalized.atofPath, + atifPath: normalized.atifPath, + }) + } catch (error) { + await logger.warnOnce( + "init-failed", + "NeMo Flow runtime unavailable; OpenCode plugin is running pass-through", + error, + ) + return {} + } + + return { + config: async (config) => adapter.recordConfig(config), + event: async ({ event }) => adapter.recordEvent(event), + "chat.message": async (hookInput, output) => adapter.recordChatMessage(hookInput, output), + "chat.params": async (hookInput, output) => adapter.recordChatParams(hookInput, output), + "tool.execute.before": async (hookInput, output) => adapter.recordToolBefore(hookInput, output), + "tool.execute.after": async (hookInput, output) => adapter.recordToolAfter(hookInput, output), + } + } +} + +export const server = createServerPlugin() + +export default { + id: PLUGIN_ID, + server, +} diff --git a/integrations/opencode-plugin/test/server.test.mjs b/integrations/opencode-plugin/test/server.test.mjs new file mode 100644 index 00000000..d254a546 --- /dev/null +++ b/integrations/opencode-plugin/test/server.test.mjs @@ -0,0 +1,346 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { describe, it } from "node:test" + +import { createServerPlugin } from "../server.js" + +function createFakeRuntime() { + const subscribers = new Map() + let counter = 0 + + function emit(event) { + for (const callback of subscribers.values()) callback(event) + } + + class AtifExporter { + constructor(sessionID, agentName, agentVersion, modelName) { + this.sessionID = sessionID + this.agentName = agentName + this.agentVersion = agentVersion + this.modelName = modelName + this.events = [] + this.callback = (event) => this.events.push(event) + } + + register(name) { + subscribers.set(name, this.callback) + } + + deregister(name) { + return subscribers.delete(name) + } + + exportJson() { + return JSON.stringify({ + session_id: this.sessionID, + agent: { + name: this.agentName, + version: this.agentVersion, + model_name: this.modelName, + }, + steps: this.events, + }) + } + } + + return { + ScopeType: { Agent: 0 }, + AtifExporter, + registerSubscriber(name, callback) { + subscribers.set(name, callback) + }, + deregisterSubscriber(name) { + return subscribers.delete(name) + }, + createScopeStack() { + return { id: `stack-${++counter}` } + }, + currentScopeStack() { + return { id: "current" } + }, + setThreadScopeStack(_stack) {}, + pushScope(name, scopeType, _parent, attributes, data, metadata, input) { + const handle = { + uuid: `scope-${++counter}`, + name, + scopeType, + attributes, + } + emit({ + kind: "scope", + category: "agent", + scope_category: "start", + uuid: handle.uuid, + name, + data, + metadata, + input, + }) + return handle + }, + popScope(handle, output) { + emit({ + kind: "scope", + category: "agent", + scope_category: "end", + uuid: handle.uuid, + name: handle.name, + data: output, + }) + }, + event(name, handle, data, metadata) { + emit({ + kind: "mark", + uuid: `mark-${++counter}`, + parent_uuid: handle?.uuid, + name, + data, + metadata, + }) + }, + toolCall(name, args, handle, attributes, data, metadata, toolCallID) { + const tool = { + uuid: `tool-${++counter}`, + name, + parentUuid: handle?.uuid, + toolCallID, + } + emit({ + kind: "scope", + category: "tool", + scope_category: "start", + uuid: tool.uuid, + name, + parent_uuid: handle?.uuid, + data: args, + metadata, + }) + return tool + }, + toolCallEnd(handle, result, data, metadata) { + emit({ + kind: "scope", + category: "tool", + scope_category: "end", + uuid: handle.uuid, + name: handle.name, + data: result ?? data, + metadata, + }) + }, + } +} + +async function makeTempDir() { + return fs.mkdtemp(path.join(os.tmpdir(), "nemo-flow-opencode-plugin-")) +} + +async function readJsonl(filePath) { + const content = await fs.readFile(filePath, "utf8") + return content + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)) +} + +describe("NeMo Flow OpenCode plugin", () => { + it("records OpenCode hooks to ATOF and flushes ATIF on idle", async () => { + const dir = await makeTempDir() + const server = createServerPlugin({ loadRuntime: async () => createFakeRuntime() }) + const hooks = await server( + { directory: dir }, + { + enabled: true, + atofPath: "./.nemoflow/opencode.atof.jsonl", + atifPath: "./.nemoflow/opencode.atif.json", + logPath: "./.nemoflow/opencode-plugin.log", + }, + ) + + await hooks.config?.({ model: "test-provider/test-model", agent: { build: {} } }) + await hooks["chat.message"]?.( + { + sessionID: "ses_1", + agent: "build", + model: { providerID: "test-provider", modelID: "test-model" }, + messageID: "msg_1", + }, + { + message: { id: "msg_1", role: "user", agent: "build" }, + parts: [{ id: "part_1", type: "text", text: "hello" }], + }, + ) + await hooks["chat.params"]?.( + { + sessionID: "ses_1", + agent: "build", + model: { providerID: "test-provider", id: "test-model" }, + provider: { source: "config", options: {} }, + message: { id: "msg_1" }, + }, + { temperature: 0, topP: 1, topK: 0, options: {} }, + ) + await hooks["tool.execute.before"]?.( + { tool: "write", sessionID: "ses_1", callID: "call_1" }, + { args: { path: "phase1-demo.txt" } }, + ) + await hooks["tool.execute.after"]?.( + { tool: "write", sessionID: "ses_1", callID: "call_1", args: { path: "phase1-demo.txt" } }, + { title: "Wrote file", output: "done", metadata: { ok: true } }, + ) + await hooks.event?.({ + event: { + id: "evt_1", + type: "session.status", + properties: { sessionID: "ses_1", status: { type: "idle" } }, + }, + }) + + const atofPath = path.join(dir, ".nemoflow", "opencode.atof.jsonl") + const atifPath = path.join(dir, ".nemoflow", "opencode.atif.json") + const atof = await fs.readFile(atofPath, "utf8") + const atif = JSON.parse(await fs.readFile(atifPath, "utf8")) + + assert.match(atof, /opencode\.chat\.message/) + assert.match(atof, /opencode\.llm\.request/) + assert.match(atof, /"category":"tool"/) + assert.equal(atif.session_id, "ses_1") + assert.ok(atif.steps.some((event) => event.name === "opencode.session.flush")) + }) + + it("records session lifecycle events, message metadata, errors, and deleted flushes", async () => { + const dir = await makeTempDir() + const server = createServerPlugin({ loadRuntime: async () => createFakeRuntime() }) + const hooks = await server( + { directory: dir }, + { + enabled: true, + atofPath: "./.nemoflow/opencode.atof.jsonl", + atifPath: "./.nemoflow/opencode.atif.json", + logPath: "./.nemoflow/opencode-plugin.log", + }, + ) + + const model = { providerID: "anthropic", modelID: "claude-test" } + await hooks.event?.({ + event: { + id: "evt_created", + type: "session.created", + properties: { + sessionID: "ses_2", + info: { id: "ses_2", agent: "review", model }, + apiKey: "secret", + outputTokens: 8, + }, + }, + }) + await hooks.event?.({ + event: { + id: "evt_updated", + type: "session.updated", + properties: { sessionID: "ses_2", info: { id: "ses_2", agent: "review", model } }, + }, + }) + await hooks["chat.message"]?.( + { + sessionID: "ses_2", + agent: "review", + model, + messageID: "msg_2", + apiKey: "secret", + outputTokens: 3, + }, + { + message: { id: "msg_2", role: "user", agent: "review" }, + parts: [{ id: "part_2", type: "text", text: "summarize this" }], + }, + ) + await hooks["chat.params"]?.( + { + sessionID: "ses_2", + agent: "review", + model, + provider: { source: "config", options: { apiKey: "secret" } }, + message: { id: "msg_2" }, + }, + { maxOutputTokens: 64, temperature: 0 }, + ) + await hooks.event?.({ + event: { + id: "evt_error", + type: "session.error", + properties: { sessionID: "ses_2", error: { message: "provider failed", apiKey: "secret" } }, + }, + }) + await hooks.event?.({ + event: { + id: "evt_deleted", + type: "session.deleted", + properties: { sessionID: "ses_2" }, + }, + }) + + const atofPath = path.join(dir, ".nemoflow", "opencode.atof.jsonl") + const atifPath = path.join(dir, ".nemoflow", "opencode.atif.json") + const events = await readJsonl(atofPath) + const names = events.map((event) => event.name).filter(Boolean) + const message = events.find((event) => event.name === "opencode.chat.message") + const serialized = JSON.stringify(events) + const atif = JSON.parse(await fs.readFile(atifPath, "utf8")) + + assert.ok(names.includes("opencode.session.created")) + assert.ok(names.includes("opencode.session.updated")) + assert.ok(names.includes("opencode.session.error")) + assert.ok(names.includes("opencode.session.deleted")) + assert.equal(message.metadata.sessionID, "ses_2") + assert.equal(message.metadata.agent, "review") + assert.equal(message.metadata.model, "anthropic/claude-test") + assert.match(serialized, /"apiKey":"\[Redacted\]"/) + assert.match(serialized, /"outputTokens":3/) + assert.equal(atif.session_id, "ses_2") + assert.ok(atif.steps.some((event) => event.name === "opencode.session.flush")) + }) + + it("ignores hooks without an OpenCode session identifier", async () => { + const dir = await makeTempDir() + const server = createServerPlugin({ loadRuntime: async () => createFakeRuntime() }) + const hooks = await server({ directory: dir }, { enabled: true }) + + await assert.doesNotReject(async () => { + await hooks["chat.message"]?.({ agent: "build" }, { message: { id: "msg_missing" } }) + await hooks["chat.params"]?.({ agent: "build", message: { id: "msg_missing" } }, {}) + await hooks["tool.execute.before"]?.({ tool: "read", callID: "call_missing" }, { args: { path: "x" } }) + await hooks["tool.execute.after"]?.({ tool: "read", callID: "call_missing" }, { output: "x" }) + }) + await assert.rejects(fs.stat(path.join(dir, ".nemoflow", "opencode.atof.jsonl"))) + }) + + it("stays pass-through when disabled", async () => { + const dir = await makeTempDir() + const server = createServerPlugin({ loadRuntime: async () => createFakeRuntime() }) + const hooks = await server({ directory: dir }, { enabled: false }) + + assert.deepEqual(hooks, {}) + await assert.rejects(fs.stat(path.join(dir, ".nemoflow", "opencode.atof.jsonl"))) + }) + + it("logs once and disables hooks when the runtime cannot load", async () => { + const dir = await makeTempDir() + const server = createServerPlugin({ + loadRuntime: async () => { + throw new Error("missing native binding") + }, + }) + const hooks = await server({ directory: dir }, { enabled: true }) + + assert.deepEqual(hooks, {}) + const log = await fs.readFile(path.join(dir, ".nemoflow", "opencode-plugin.log"), "utf8") + assert.match(log, /pass-through/) + }) +}) diff --git a/opencode-nemoflow-integration-plan.md b/opencode-nemoflow-integration-plan.md new file mode 100644 index 00000000..8d0c79ae --- /dev/null +++ b/opencode-nemoflow-integration-plan.md @@ -0,0 +1,703 @@ +# OpenCode <> NeMo Flow Integration Plan + +Reference OpenCode checkout: `reference_projects/opencode` + +Reference commit: `dcfe4b0d5184cb93dd2232f1461641d6530e1abb` + +## Goal + +The end goal is a proper OpenCode plugin, with no NeMo Flow-maintained patch +against OpenCode. + +The immediate goal is narrower than full NeMo Flow middleware support: + +1. Build a standalone OpenCode observability plugin using OpenCode's existing + public plugin API. +2. Preserve OpenCode's in-agent session, message, model, tool, and error + context. +3. Export NeMo Flow observability data from inside OpenCode, not from a + sidecar/proxy-only view. + +The current NeMo Flow patch should be treated as a prototype and reference +implementation only. It proves where OpenCode needs plugin extension points, +but it is not the target deliverable. + +The target deliverable for the first milestone is: + +1. NeMo Flow ships an OpenCode plugin that uses only OpenCode's public plugin + API. +2. OpenCode does not contain NeMo Flow-specific code, dependencies, flags, or + config schema. +3. Users can enable the integration through normal OpenCode plugin + configuration, without applying patches. + +Future NeMo Flow intercepts and guardrails may require new OpenCode plugin +hooks, but that is a later milestone after the observability plugin is working. + +The key distinction is: + +- Existing OpenCode hooks are enough for a useful observability plugin. +- Existing OpenCode hooks are not enough for full NeMo Flow middleware behavior + such as request intercepts, execution intercepts, conditional guardrails, and + stream intercepts. +- Switchyard or other proxy interception can observe provider traffic, but it + does not own OpenCode's agent context and hierarchy. The OpenCode plugin + should be the source of agent/session structure. + +## Current OpenCode Plugin Surface + +OpenCode server plugins are loaded from `packages/opencode/src/plugin/index.ts`. +They implement the `Hooks` interface from `packages/plugin/src/index.ts`. + +Current useful hooks for the first observability milestone: + +| Hook | Current behavior | Useful for NeMo Flow observability | Gap for future intercepts | +| --- | --- | --- | --- | +| `config(input)` | Notifies plugins after OpenCode config is loaded. | Initialize NeMo Flow runtime/exporters from plugin options and OpenCode config context. | Enough. | +| `event({ event })` | Receives all OpenCode bus events. | Export session lifecycle, message updates, errors, idle events, and final ATIF/ATOF output. | Enough for passive observability only. | +| `chat.message(input, output)` | Called when a user message is created. | Bind session, agent, model, user message, and turn-level metadata. | Useful, but not a full LLM execution boundary. | +| `chat.params(input, output)` | Lets plugins inspect or mutate model params before the AI SDK call. | Capture provider/model/parameter metadata and approximate LLM request start. | Too narrow for full LLM request rewrite; does not wrap execution or stream lifecycle. | +| `chat.headers(input, output)` | Lets plugins add provider request headers. | Optional correlation header injection for external tracing. | Too narrow for content/message/tool/provider rewrites. | +| `tool.execute.before(input, output)` | Called before tool execution with tool name, session, call id, args. | Start a tool span and capture sanitized input. | Cannot wrap the callback, cannot reliably see thrown errors, cannot apply NeMo Flow execution intercepts. | +| `tool.execute.after(input, output)` | Called after successful tool execution. | Finish a successful tool span and capture sanitized output. | Does not run on every error path unless OpenCode manually catches; cannot alter result. | +| `permission.ask(input, output)` | Lets plugins observe or influence permission decisions. | Potential policy correlation. | Not a tool/LLM execution boundary. | +| `command.execute.before(input, output)` | Called before slash command execution. | Optional command observability. | Not core LLM/tool middleware. | +| `shell.env(input, output)` | Lets plugins mutate shell env. | Optional shell correlation. | Not core LLM/tool middleware. | +| `experimental.chat.messages.transform(input, output)` | Mutates model message history before LLM call. | Possible request shaping. | Experimental and message-only; does not cover stream lifecycle or execution intercepts. | +| `experimental.chat.system.transform(input, output)` | Mutates system prompts. | Possible prompt shaping. | Experimental and system-only. | +| `experimental.session.compacting(input, output)` | Compaction hook. | Optional lifecycle correlation. | Not core LLM/tool middleware. | +| `experimental.compaction.autocontinue(input, output)` | Compaction continuation hook. | Optional lifecycle correlation. | Not core LLM/tool middleware. | +| `experimental.text.complete(input, output)` | Text completion hook. | Optional smaller LLM observability. | Separate from main chat stream. | +| `tool.definition` | Contributes plugin-defined tools. | Useful for plugin-provided tools. | Does not wrap built-in or MCP tools. | + +## What NeMo Flow Needs + +NeMo Flow has two different classes of behavior. The first milestone should +focus only on observability. + +| NeMo Flow behavior | Changes real execution? | Required OpenCode support | +| --- | --- | --- | +| Subscribers/exporters | No. Observes emitted runtime events. | Existing `event`, `config`, `chat.message`, `chat.params`, and tool before/after hooks are enough for an MVP. | +| Scoped lifecycle | No direct mutation, but affects event hierarchy. | Existing OpenCode session/message/tool events are enough to build useful hierarchy. | +| Sanitize guardrails | No. Redacts observability payloads only. | Can be applied inside the plugin before exporting. | +| Request intercepts | Yes. Rewrites tool args or LLM request payload. | Later milestone. Needs OpenCode to pass rewritten request/args into the real callback. | +| Execution intercepts | Yes. Wraps, replaces, retries, caches, or short-circuits execution. | Later milestone. Needs an around hook with `next`. | +| Stream execution intercepts | Yes. Wraps async LLM streams. | Later milestone. Needs an around hook for the stream producer. | +| Conditional guardrails | Yes. Blocks execution. | Later milestone. Needs a managed boundary before the real callback. | + +## Observability Plugin MVP + +The first plugin should be a normal OpenCode server plugin distributed like +community plugins. + +It should use only the existing OpenCode plugin API: + +| OpenCode hook | NeMo Flow plugin responsibility | +| --- | --- | +| `config` | Read plugin options, initialize NeMo Flow, set exporter paths, and log disabled/misconfigured state once. | +| `event` | Build session/message/error lifecycle records and flush/export when a session becomes idle or deleted. | +| `chat.message` | Create or update a turn/session record with user message, agent, and selected model metadata. | +| `chat.params` | Capture provider/model/parameter metadata near the LLM request boundary. | +| `tool.execute.before` | Start a tool span using `sessionID`, `callID`, tool name, and sanitized args. | +| `tool.execute.after` | Finish a successful tool span with title/output/metadata. | + +Expected first-milestone output: + +1. Session-level ATIF/ATOF records with OpenCode session IDs. +2. Message and turn metadata with agent and model context where available. +3. Tool spans for successful builtin, MCP, plugin, and task tool calls where + OpenCode emits before/after hooks. +4. Session error records from OpenCode events. +5. A documented limitation for exact LLM stream boundaries and tool error spans + until OpenCode exposes around-style hooks. + +Switchyard can still be useful for provider-level request/response capture, but +it should not replace the OpenCode plugin for agent context. The plugin should +own hierarchy; proxy data can be correlated later if needed. + +## Version Compatibility Strategy + +The NeMo Flow OpenCode plugin should follow the existing OpenCode community +plugin pattern: + +1. Publish or build as a normal npm package with a server plugin entrypoint, + for example `exports["./server"]`. +2. Depend on `@opencode-ai/plugin` and, if needed, `@opencode-ai/sdk` using a + semver range. +3. Declare supported OpenCode versions with `engines.opencode`, for example + `">=1.3.13"` once the minimum tested version is chosen. +4. Do not pin an OpenCode source checkout as part of the plugin runtime. +5. Use CI to test the minimum supported OpenCode version and latest stable + OpenCode. + +Pinned `reference_projects/opencode` and `third_party/opencode` checkouts are +still useful for development and regression testing, but they should not become +the plugin installation model. + +## Future Intercept Hooks + +After the observability plugin works, NeMo Flow can evaluate whether OpenCode +needs new generic plugin hooks for real execution intercepts. + +The likely missing first-party OpenCode hooks are: + +```ts +llm.stream.wrap(input, next) +tool.execute.wrap(input, next) +``` + +The names are placeholders. The important part is the semantics: OpenCode owns +the integration point, but the plugin can wrap the real callback and decide +whether and how to call `next`. + +## Future First-Party Hook Shapes + +### LLM Stream Hook + +```ts +type LlmStreamWrapInput = { + sessionID: string + parentSessionID?: string + agent: string + model: Model + provider: ProviderContext + message: UserMessage + request: { + system: string[] + messages: ModelMessage[] + tools: Record + toolChoice?: "auto" | "required" | "none" + params: { + temperature?: number + topP?: number + topK?: number + maxOutputTokens?: number + options: Record + } + headers: Record + } +} + +type LlmStreamWrapNext = ( + input: LlmStreamWrapInput, +) => AsyncIterable | Promise> + +type LlmStreamWrapHook = ( + input: LlmStreamWrapInput, + next: LlmStreamWrapNext, +) => AsyncIterable | Promise> +``` + +Expected semantics: + +1. OpenCode builds the final request object before calling the AI SDK. +2. OpenCode calls plugins as a nested chain. +3. The NeMo Flow plugin serializes the request, runs NeMo Flow + `llm_stream_execute`, applies request intercept results, then calls `next`. +4. OpenCode sends the final rewritten request to the provider. +5. Chunks flow back through the wrapper so NeMo Flow can emit stream start, + chunk, end, and error events. + +### Tool Execute Hook + +```ts +type ToolExecuteWrapInput = { + tool: string + sessionID: string + callID: string + args: unknown + source: "builtin" | "mcp" | "task" | "plugin" +} + +type ToolExecuteWrapNext = (input: ToolExecuteWrapInput) => Promise + +type ToolExecuteWrapHook = ( + input: ToolExecuteWrapInput, + next: ToolExecuteWrapNext, +) => Promise +``` + +Expected semantics: + +1. OpenCode validates tool input and constructs the tool execution context. +2. OpenCode calls the wrapper chain. +3. The NeMo Flow plugin runs `tool_call_execute`. +4. NeMo Flow request intercepts can rewrite `input.args`. +5. NeMo Flow execution intercepts can call `next`, skip `next`, retry, cache, or + replace the result. +6. OpenCode receives the final result and continues its normal message update + path. + +## Hook Composition + +OpenCode currently runs hooks sequentially in plugin load order. Around hooks +should preserve that simplicity: + +```ts +function composeWrapHooks(hooks, finalNext) { + return hooks.reduceRight( + (next, hook) => (input) => hook(input, next), + finalNext, + ) +} +``` + +No priority field is required for the first version. Load order is enough and +matches the rest of the plugin system. A priority field can be added later only +if OpenCode wants deterministic ordering independent of config order. + +## Implementation Breakdown + +### Phase 0: Use The Existing Patch As Reference Only + +The current NeMo Flow patch is useful for learning and validation, but it +should not be the production integration strategy. + +Use it to answer these questions: + +1. Which OpenCode events are needed for session/message hierarchy? +2. Which existing hooks provide agent/model/tool metadata? +3. Which ATOF/ATIF output can be produced without patching OpenCode? +4. Which exact behaviors remain impossible without around-style hooks? + +Do not add more NeMo Flow-specific code to OpenCode as the long-term path. + +### Shared Smoke Test Setup + +Use this setup for every phase demo. The goal is to show the integration as an +end user would run it, not as a patched OpenCode checkout. + +Assumptions: + +1. Node.js 20 or newer is installed. +2. OpenCode can reach at least one configured model provider. +3. `jq` is installed for checking JSON demo output. +4. The NeMo Flow OpenCode plugin package has been built or published. +5. The final package name is still open. The examples below use + `@nvidia/nemoflow-opencode-plugin` as a placeholder. + +Common commands: + +```bash +git clone https://github.com/NVIDIA/NeMo-Flow.git +cd NeMo-Flow + +npm install -g opencode-ai@latest +opencode --version + +export NEMO_FLOW_OPENCODE_PLUGIN="@nvidia/nemoflow-opencode-plugin" +export NEMO_FLOW_DEMO_DIR="$PWD/tmp/opencode-nemoflow-demo" + +rm -rf "$NEMO_FLOW_DEMO_DIR" +mkdir -p "$NEMO_FLOW_DEMO_DIR/.nemoflow" +cd "$NEMO_FLOW_DEMO_DIR" + +cat > opencode.json < opencode.disabled.json +mv opencode.disabled.json opencode.json +rm -f ./.nemoflow/* + +opencode run \ + --title "nemo-flow phase 2 disabled smoke" \ + "Reply with exactly: plugin disabled smoke." + +ls -la ./.nemoflow +mv opencode.enabled.json opencode.json +``` + +Expected disabled output: + +1. OpenCode still completes the run. +2. The NeMo Flow output files are absent or empty. + +Run the init-failure path only if the plugin exposes a demo failure switch: + +```bash +rm -f ./.nemoflow/* +NEMO_FLOW_OPENCODE_FORCE_INIT_FAILURE=1 opencode run \ + --title "nemo-flow phase 2 init failure smoke" \ + "Reply with exactly: init failure smoke." + +grep -i "failed\\|disabled\\|pass-through" ./.nemoflow/opencode-plugin.log +``` + +Expected failure output: + +1. OpenCode still completes the run. +2. The plugin log records one clear pass-through message. +3. No partial or corrupt ATIF/ATOF output is written. + +### Phase 3: Retire The Patch For Observability + +Once the observability plugin works: + +1. Stop treating the OpenCode patch as the observability integration path. +2. Remove OpenCode-specific NeMo Flow flags, config schema, and internal plugin + code from the OpenCode tree. +3. Update NeMo Flow docs to explain how to install and configure the OpenCode + plugin. +4. Keep the old patch only as temporary reference material if it is still useful + for the later intercept investigation. + +#### Smoke Test Guide + +This demo proves the third promised feature: the observability integration works +with a stock OpenCode install and does not depend on the NeMo Flow OpenCode +patch. + +Run from a fresh directory outside the NeMo Flow repository: + +```bash +export CLEAN_DEMO_DIR="$(mktemp -d)" +cd "$CLEAN_DEMO_DIR" +mkdir -p .nemoflow + +npm install -g opencode-ai@latest +opencode --version | tee ./.nemoflow/opencode-version.txt +which opencode | tee ./.nemoflow/opencode-path.txt + +cat > opencode.json < Date: Fri, 8 May 2026 15:17:37 -0400 Subject: [PATCH 3/7] nit --- opencode-nemoflow-integration-plan.md | 703 -------------------------- 1 file changed, 703 deletions(-) delete mode 100644 opencode-nemoflow-integration-plan.md diff --git a/opencode-nemoflow-integration-plan.md b/opencode-nemoflow-integration-plan.md deleted file mode 100644 index 8d0c79ae..00000000 --- a/opencode-nemoflow-integration-plan.md +++ /dev/null @@ -1,703 +0,0 @@ -# OpenCode <> NeMo Flow Integration Plan - -Reference OpenCode checkout: `reference_projects/opencode` - -Reference commit: `dcfe4b0d5184cb93dd2232f1461641d6530e1abb` - -## Goal - -The end goal is a proper OpenCode plugin, with no NeMo Flow-maintained patch -against OpenCode. - -The immediate goal is narrower than full NeMo Flow middleware support: - -1. Build a standalone OpenCode observability plugin using OpenCode's existing - public plugin API. -2. Preserve OpenCode's in-agent session, message, model, tool, and error - context. -3. Export NeMo Flow observability data from inside OpenCode, not from a - sidecar/proxy-only view. - -The current NeMo Flow patch should be treated as a prototype and reference -implementation only. It proves where OpenCode needs plugin extension points, -but it is not the target deliverable. - -The target deliverable for the first milestone is: - -1. NeMo Flow ships an OpenCode plugin that uses only OpenCode's public plugin - API. -2. OpenCode does not contain NeMo Flow-specific code, dependencies, flags, or - config schema. -3. Users can enable the integration through normal OpenCode plugin - configuration, without applying patches. - -Future NeMo Flow intercepts and guardrails may require new OpenCode plugin -hooks, but that is a later milestone after the observability plugin is working. - -The key distinction is: - -- Existing OpenCode hooks are enough for a useful observability plugin. -- Existing OpenCode hooks are not enough for full NeMo Flow middleware behavior - such as request intercepts, execution intercepts, conditional guardrails, and - stream intercepts. -- Switchyard or other proxy interception can observe provider traffic, but it - does not own OpenCode's agent context and hierarchy. The OpenCode plugin - should be the source of agent/session structure. - -## Current OpenCode Plugin Surface - -OpenCode server plugins are loaded from `packages/opencode/src/plugin/index.ts`. -They implement the `Hooks` interface from `packages/plugin/src/index.ts`. - -Current useful hooks for the first observability milestone: - -| Hook | Current behavior | Useful for NeMo Flow observability | Gap for future intercepts | -| --- | --- | --- | --- | -| `config(input)` | Notifies plugins after OpenCode config is loaded. | Initialize NeMo Flow runtime/exporters from plugin options and OpenCode config context. | Enough. | -| `event({ event })` | Receives all OpenCode bus events. | Export session lifecycle, message updates, errors, idle events, and final ATIF/ATOF output. | Enough for passive observability only. | -| `chat.message(input, output)` | Called when a user message is created. | Bind session, agent, model, user message, and turn-level metadata. | Useful, but not a full LLM execution boundary. | -| `chat.params(input, output)` | Lets plugins inspect or mutate model params before the AI SDK call. | Capture provider/model/parameter metadata and approximate LLM request start. | Too narrow for full LLM request rewrite; does not wrap execution or stream lifecycle. | -| `chat.headers(input, output)` | Lets plugins add provider request headers. | Optional correlation header injection for external tracing. | Too narrow for content/message/tool/provider rewrites. | -| `tool.execute.before(input, output)` | Called before tool execution with tool name, session, call id, args. | Start a tool span and capture sanitized input. | Cannot wrap the callback, cannot reliably see thrown errors, cannot apply NeMo Flow execution intercepts. | -| `tool.execute.after(input, output)` | Called after successful tool execution. | Finish a successful tool span and capture sanitized output. | Does not run on every error path unless OpenCode manually catches; cannot alter result. | -| `permission.ask(input, output)` | Lets plugins observe or influence permission decisions. | Potential policy correlation. | Not a tool/LLM execution boundary. | -| `command.execute.before(input, output)` | Called before slash command execution. | Optional command observability. | Not core LLM/tool middleware. | -| `shell.env(input, output)` | Lets plugins mutate shell env. | Optional shell correlation. | Not core LLM/tool middleware. | -| `experimental.chat.messages.transform(input, output)` | Mutates model message history before LLM call. | Possible request shaping. | Experimental and message-only; does not cover stream lifecycle or execution intercepts. | -| `experimental.chat.system.transform(input, output)` | Mutates system prompts. | Possible prompt shaping. | Experimental and system-only. | -| `experimental.session.compacting(input, output)` | Compaction hook. | Optional lifecycle correlation. | Not core LLM/tool middleware. | -| `experimental.compaction.autocontinue(input, output)` | Compaction continuation hook. | Optional lifecycle correlation. | Not core LLM/tool middleware. | -| `experimental.text.complete(input, output)` | Text completion hook. | Optional smaller LLM observability. | Separate from main chat stream. | -| `tool.definition` | Contributes plugin-defined tools. | Useful for plugin-provided tools. | Does not wrap built-in or MCP tools. | - -## What NeMo Flow Needs - -NeMo Flow has two different classes of behavior. The first milestone should -focus only on observability. - -| NeMo Flow behavior | Changes real execution? | Required OpenCode support | -| --- | --- | --- | -| Subscribers/exporters | No. Observes emitted runtime events. | Existing `event`, `config`, `chat.message`, `chat.params`, and tool before/after hooks are enough for an MVP. | -| Scoped lifecycle | No direct mutation, but affects event hierarchy. | Existing OpenCode session/message/tool events are enough to build useful hierarchy. | -| Sanitize guardrails | No. Redacts observability payloads only. | Can be applied inside the plugin before exporting. | -| Request intercepts | Yes. Rewrites tool args or LLM request payload. | Later milestone. Needs OpenCode to pass rewritten request/args into the real callback. | -| Execution intercepts | Yes. Wraps, replaces, retries, caches, or short-circuits execution. | Later milestone. Needs an around hook with `next`. | -| Stream execution intercepts | Yes. Wraps async LLM streams. | Later milestone. Needs an around hook for the stream producer. | -| Conditional guardrails | Yes. Blocks execution. | Later milestone. Needs a managed boundary before the real callback. | - -## Observability Plugin MVP - -The first plugin should be a normal OpenCode server plugin distributed like -community plugins. - -It should use only the existing OpenCode plugin API: - -| OpenCode hook | NeMo Flow plugin responsibility | -| --- | --- | -| `config` | Read plugin options, initialize NeMo Flow, set exporter paths, and log disabled/misconfigured state once. | -| `event` | Build session/message/error lifecycle records and flush/export when a session becomes idle or deleted. | -| `chat.message` | Create or update a turn/session record with user message, agent, and selected model metadata. | -| `chat.params` | Capture provider/model/parameter metadata near the LLM request boundary. | -| `tool.execute.before` | Start a tool span using `sessionID`, `callID`, tool name, and sanitized args. | -| `tool.execute.after` | Finish a successful tool span with title/output/metadata. | - -Expected first-milestone output: - -1. Session-level ATIF/ATOF records with OpenCode session IDs. -2. Message and turn metadata with agent and model context where available. -3. Tool spans for successful builtin, MCP, plugin, and task tool calls where - OpenCode emits before/after hooks. -4. Session error records from OpenCode events. -5. A documented limitation for exact LLM stream boundaries and tool error spans - until OpenCode exposes around-style hooks. - -Switchyard can still be useful for provider-level request/response capture, but -it should not replace the OpenCode plugin for agent context. The plugin should -own hierarchy; proxy data can be correlated later if needed. - -## Version Compatibility Strategy - -The NeMo Flow OpenCode plugin should follow the existing OpenCode community -plugin pattern: - -1. Publish or build as a normal npm package with a server plugin entrypoint, - for example `exports["./server"]`. -2. Depend on `@opencode-ai/plugin` and, if needed, `@opencode-ai/sdk` using a - semver range. -3. Declare supported OpenCode versions with `engines.opencode`, for example - `">=1.3.13"` once the minimum tested version is chosen. -4. Do not pin an OpenCode source checkout as part of the plugin runtime. -5. Use CI to test the minimum supported OpenCode version and latest stable - OpenCode. - -Pinned `reference_projects/opencode` and `third_party/opencode` checkouts are -still useful for development and regression testing, but they should not become -the plugin installation model. - -## Future Intercept Hooks - -After the observability plugin works, NeMo Flow can evaluate whether OpenCode -needs new generic plugin hooks for real execution intercepts. - -The likely missing first-party OpenCode hooks are: - -```ts -llm.stream.wrap(input, next) -tool.execute.wrap(input, next) -``` - -The names are placeholders. The important part is the semantics: OpenCode owns -the integration point, but the plugin can wrap the real callback and decide -whether and how to call `next`. - -## Future First-Party Hook Shapes - -### LLM Stream Hook - -```ts -type LlmStreamWrapInput = { - sessionID: string - parentSessionID?: string - agent: string - model: Model - provider: ProviderContext - message: UserMessage - request: { - system: string[] - messages: ModelMessage[] - tools: Record - toolChoice?: "auto" | "required" | "none" - params: { - temperature?: number - topP?: number - topK?: number - maxOutputTokens?: number - options: Record - } - headers: Record - } -} - -type LlmStreamWrapNext = ( - input: LlmStreamWrapInput, -) => AsyncIterable | Promise> - -type LlmStreamWrapHook = ( - input: LlmStreamWrapInput, - next: LlmStreamWrapNext, -) => AsyncIterable | Promise> -``` - -Expected semantics: - -1. OpenCode builds the final request object before calling the AI SDK. -2. OpenCode calls plugins as a nested chain. -3. The NeMo Flow plugin serializes the request, runs NeMo Flow - `llm_stream_execute`, applies request intercept results, then calls `next`. -4. OpenCode sends the final rewritten request to the provider. -5. Chunks flow back through the wrapper so NeMo Flow can emit stream start, - chunk, end, and error events. - -### Tool Execute Hook - -```ts -type ToolExecuteWrapInput = { - tool: string - sessionID: string - callID: string - args: unknown - source: "builtin" | "mcp" | "task" | "plugin" -} - -type ToolExecuteWrapNext = (input: ToolExecuteWrapInput) => Promise - -type ToolExecuteWrapHook = ( - input: ToolExecuteWrapInput, - next: ToolExecuteWrapNext, -) => Promise -``` - -Expected semantics: - -1. OpenCode validates tool input and constructs the tool execution context. -2. OpenCode calls the wrapper chain. -3. The NeMo Flow plugin runs `tool_call_execute`. -4. NeMo Flow request intercepts can rewrite `input.args`. -5. NeMo Flow execution intercepts can call `next`, skip `next`, retry, cache, or - replace the result. -6. OpenCode receives the final result and continues its normal message update - path. - -## Hook Composition - -OpenCode currently runs hooks sequentially in plugin load order. Around hooks -should preserve that simplicity: - -```ts -function composeWrapHooks(hooks, finalNext) { - return hooks.reduceRight( - (next, hook) => (input) => hook(input, next), - finalNext, - ) -} -``` - -No priority field is required for the first version. Load order is enough and -matches the rest of the plugin system. A priority field can be added later only -if OpenCode wants deterministic ordering independent of config order. - -## Implementation Breakdown - -### Phase 0: Use The Existing Patch As Reference Only - -The current NeMo Flow patch is useful for learning and validation, but it -should not be the production integration strategy. - -Use it to answer these questions: - -1. Which OpenCode events are needed for session/message hierarchy? -2. Which existing hooks provide agent/model/tool metadata? -3. Which ATOF/ATIF output can be produced without patching OpenCode? -4. Which exact behaviors remain impossible without around-style hooks? - -Do not add more NeMo Flow-specific code to OpenCode as the long-term path. - -### Shared Smoke Test Setup - -Use this setup for every phase demo. The goal is to show the integration as an -end user would run it, not as a patched OpenCode checkout. - -Assumptions: - -1. Node.js 20 or newer is installed. -2. OpenCode can reach at least one configured model provider. -3. `jq` is installed for checking JSON demo output. -4. The NeMo Flow OpenCode plugin package has been built or published. -5. The final package name is still open. The examples below use - `@nvidia/nemoflow-opencode-plugin` as a placeholder. - -Common commands: - -```bash -git clone https://github.com/NVIDIA/NeMo-Flow.git -cd NeMo-Flow - -npm install -g opencode-ai@latest -opencode --version - -export NEMO_FLOW_OPENCODE_PLUGIN="@nvidia/nemoflow-opencode-plugin" -export NEMO_FLOW_DEMO_DIR="$PWD/tmp/opencode-nemoflow-demo" - -rm -rf "$NEMO_FLOW_DEMO_DIR" -mkdir -p "$NEMO_FLOW_DEMO_DIR/.nemoflow" -cd "$NEMO_FLOW_DEMO_DIR" - -cat > opencode.json < opencode.disabled.json -mv opencode.disabled.json opencode.json -rm -f ./.nemoflow/* - -opencode run \ - --title "nemo-flow phase 2 disabled smoke" \ - "Reply with exactly: plugin disabled smoke." - -ls -la ./.nemoflow -mv opencode.enabled.json opencode.json -``` - -Expected disabled output: - -1. OpenCode still completes the run. -2. The NeMo Flow output files are absent or empty. - -Run the init-failure path only if the plugin exposes a demo failure switch: - -```bash -rm -f ./.nemoflow/* -NEMO_FLOW_OPENCODE_FORCE_INIT_FAILURE=1 opencode run \ - --title "nemo-flow phase 2 init failure smoke" \ - "Reply with exactly: init failure smoke." - -grep -i "failed\\|disabled\\|pass-through" ./.nemoflow/opencode-plugin.log -``` - -Expected failure output: - -1. OpenCode still completes the run. -2. The plugin log records one clear pass-through message. -3. No partial or corrupt ATIF/ATOF output is written. - -### Phase 3: Retire The Patch For Observability - -Once the observability plugin works: - -1. Stop treating the OpenCode patch as the observability integration path. -2. Remove OpenCode-specific NeMo Flow flags, config schema, and internal plugin - code from the OpenCode tree. -3. Update NeMo Flow docs to explain how to install and configure the OpenCode - plugin. -4. Keep the old patch only as temporary reference material if it is still useful - for the later intercept investigation. - -#### Smoke Test Guide - -This demo proves the third promised feature: the observability integration works -with a stock OpenCode install and does not depend on the NeMo Flow OpenCode -patch. - -Run from a fresh directory outside the NeMo Flow repository: - -```bash -export CLEAN_DEMO_DIR="$(mktemp -d)" -cd "$CLEAN_DEMO_DIR" -mkdir -p .nemoflow - -npm install -g opencode-ai@latest -opencode --version | tee ./.nemoflow/opencode-version.txt -which opencode | tee ./.nemoflow/opencode-path.txt - -cat > opencode.json < Date: Fri, 8 May 2026 15:19:14 -0400 Subject: [PATCH 4/7] nit --- opencode-nemoflow-integration-plan.md | 703 -------------------------- 1 file changed, 703 deletions(-) delete mode 100644 opencode-nemoflow-integration-plan.md diff --git a/opencode-nemoflow-integration-plan.md b/opencode-nemoflow-integration-plan.md deleted file mode 100644 index 8d0c79ae..00000000 --- a/opencode-nemoflow-integration-plan.md +++ /dev/null @@ -1,703 +0,0 @@ -# OpenCode <> NeMo Flow Integration Plan - -Reference OpenCode checkout: `reference_projects/opencode` - -Reference commit: `dcfe4b0d5184cb93dd2232f1461641d6530e1abb` - -## Goal - -The end goal is a proper OpenCode plugin, with no NeMo Flow-maintained patch -against OpenCode. - -The immediate goal is narrower than full NeMo Flow middleware support: - -1. Build a standalone OpenCode observability plugin using OpenCode's existing - public plugin API. -2. Preserve OpenCode's in-agent session, message, model, tool, and error - context. -3. Export NeMo Flow observability data from inside OpenCode, not from a - sidecar/proxy-only view. - -The current NeMo Flow patch should be treated as a prototype and reference -implementation only. It proves where OpenCode needs plugin extension points, -but it is not the target deliverable. - -The target deliverable for the first milestone is: - -1. NeMo Flow ships an OpenCode plugin that uses only OpenCode's public plugin - API. -2. OpenCode does not contain NeMo Flow-specific code, dependencies, flags, or - config schema. -3. Users can enable the integration through normal OpenCode plugin - configuration, without applying patches. - -Future NeMo Flow intercepts and guardrails may require new OpenCode plugin -hooks, but that is a later milestone after the observability plugin is working. - -The key distinction is: - -- Existing OpenCode hooks are enough for a useful observability plugin. -- Existing OpenCode hooks are not enough for full NeMo Flow middleware behavior - such as request intercepts, execution intercepts, conditional guardrails, and - stream intercepts. -- Switchyard or other proxy interception can observe provider traffic, but it - does not own OpenCode's agent context and hierarchy. The OpenCode plugin - should be the source of agent/session structure. - -## Current OpenCode Plugin Surface - -OpenCode server plugins are loaded from `packages/opencode/src/plugin/index.ts`. -They implement the `Hooks` interface from `packages/plugin/src/index.ts`. - -Current useful hooks for the first observability milestone: - -| Hook | Current behavior | Useful for NeMo Flow observability | Gap for future intercepts | -| --- | --- | --- | --- | -| `config(input)` | Notifies plugins after OpenCode config is loaded. | Initialize NeMo Flow runtime/exporters from plugin options and OpenCode config context. | Enough. | -| `event({ event })` | Receives all OpenCode bus events. | Export session lifecycle, message updates, errors, idle events, and final ATIF/ATOF output. | Enough for passive observability only. | -| `chat.message(input, output)` | Called when a user message is created. | Bind session, agent, model, user message, and turn-level metadata. | Useful, but not a full LLM execution boundary. | -| `chat.params(input, output)` | Lets plugins inspect or mutate model params before the AI SDK call. | Capture provider/model/parameter metadata and approximate LLM request start. | Too narrow for full LLM request rewrite; does not wrap execution or stream lifecycle. | -| `chat.headers(input, output)` | Lets plugins add provider request headers. | Optional correlation header injection for external tracing. | Too narrow for content/message/tool/provider rewrites. | -| `tool.execute.before(input, output)` | Called before tool execution with tool name, session, call id, args. | Start a tool span and capture sanitized input. | Cannot wrap the callback, cannot reliably see thrown errors, cannot apply NeMo Flow execution intercepts. | -| `tool.execute.after(input, output)` | Called after successful tool execution. | Finish a successful tool span and capture sanitized output. | Does not run on every error path unless OpenCode manually catches; cannot alter result. | -| `permission.ask(input, output)` | Lets plugins observe or influence permission decisions. | Potential policy correlation. | Not a tool/LLM execution boundary. | -| `command.execute.before(input, output)` | Called before slash command execution. | Optional command observability. | Not core LLM/tool middleware. | -| `shell.env(input, output)` | Lets plugins mutate shell env. | Optional shell correlation. | Not core LLM/tool middleware. | -| `experimental.chat.messages.transform(input, output)` | Mutates model message history before LLM call. | Possible request shaping. | Experimental and message-only; does not cover stream lifecycle or execution intercepts. | -| `experimental.chat.system.transform(input, output)` | Mutates system prompts. | Possible prompt shaping. | Experimental and system-only. | -| `experimental.session.compacting(input, output)` | Compaction hook. | Optional lifecycle correlation. | Not core LLM/tool middleware. | -| `experimental.compaction.autocontinue(input, output)` | Compaction continuation hook. | Optional lifecycle correlation. | Not core LLM/tool middleware. | -| `experimental.text.complete(input, output)` | Text completion hook. | Optional smaller LLM observability. | Separate from main chat stream. | -| `tool.definition` | Contributes plugin-defined tools. | Useful for plugin-provided tools. | Does not wrap built-in or MCP tools. | - -## What NeMo Flow Needs - -NeMo Flow has two different classes of behavior. The first milestone should -focus only on observability. - -| NeMo Flow behavior | Changes real execution? | Required OpenCode support | -| --- | --- | --- | -| Subscribers/exporters | No. Observes emitted runtime events. | Existing `event`, `config`, `chat.message`, `chat.params`, and tool before/after hooks are enough for an MVP. | -| Scoped lifecycle | No direct mutation, but affects event hierarchy. | Existing OpenCode session/message/tool events are enough to build useful hierarchy. | -| Sanitize guardrails | No. Redacts observability payloads only. | Can be applied inside the plugin before exporting. | -| Request intercepts | Yes. Rewrites tool args or LLM request payload. | Later milestone. Needs OpenCode to pass rewritten request/args into the real callback. | -| Execution intercepts | Yes. Wraps, replaces, retries, caches, or short-circuits execution. | Later milestone. Needs an around hook with `next`. | -| Stream execution intercepts | Yes. Wraps async LLM streams. | Later milestone. Needs an around hook for the stream producer. | -| Conditional guardrails | Yes. Blocks execution. | Later milestone. Needs a managed boundary before the real callback. | - -## Observability Plugin MVP - -The first plugin should be a normal OpenCode server plugin distributed like -community plugins. - -It should use only the existing OpenCode plugin API: - -| OpenCode hook | NeMo Flow plugin responsibility | -| --- | --- | -| `config` | Read plugin options, initialize NeMo Flow, set exporter paths, and log disabled/misconfigured state once. | -| `event` | Build session/message/error lifecycle records and flush/export when a session becomes idle or deleted. | -| `chat.message` | Create or update a turn/session record with user message, agent, and selected model metadata. | -| `chat.params` | Capture provider/model/parameter metadata near the LLM request boundary. | -| `tool.execute.before` | Start a tool span using `sessionID`, `callID`, tool name, and sanitized args. | -| `tool.execute.after` | Finish a successful tool span with title/output/metadata. | - -Expected first-milestone output: - -1. Session-level ATIF/ATOF records with OpenCode session IDs. -2. Message and turn metadata with agent and model context where available. -3. Tool spans for successful builtin, MCP, plugin, and task tool calls where - OpenCode emits before/after hooks. -4. Session error records from OpenCode events. -5. A documented limitation for exact LLM stream boundaries and tool error spans - until OpenCode exposes around-style hooks. - -Switchyard can still be useful for provider-level request/response capture, but -it should not replace the OpenCode plugin for agent context. The plugin should -own hierarchy; proxy data can be correlated later if needed. - -## Version Compatibility Strategy - -The NeMo Flow OpenCode plugin should follow the existing OpenCode community -plugin pattern: - -1. Publish or build as a normal npm package with a server plugin entrypoint, - for example `exports["./server"]`. -2. Depend on `@opencode-ai/plugin` and, if needed, `@opencode-ai/sdk` using a - semver range. -3. Declare supported OpenCode versions with `engines.opencode`, for example - `">=1.3.13"` once the minimum tested version is chosen. -4. Do not pin an OpenCode source checkout as part of the plugin runtime. -5. Use CI to test the minimum supported OpenCode version and latest stable - OpenCode. - -Pinned `reference_projects/opencode` and `third_party/opencode` checkouts are -still useful for development and regression testing, but they should not become -the plugin installation model. - -## Future Intercept Hooks - -After the observability plugin works, NeMo Flow can evaluate whether OpenCode -needs new generic plugin hooks for real execution intercepts. - -The likely missing first-party OpenCode hooks are: - -```ts -llm.stream.wrap(input, next) -tool.execute.wrap(input, next) -``` - -The names are placeholders. The important part is the semantics: OpenCode owns -the integration point, but the plugin can wrap the real callback and decide -whether and how to call `next`. - -## Future First-Party Hook Shapes - -### LLM Stream Hook - -```ts -type LlmStreamWrapInput = { - sessionID: string - parentSessionID?: string - agent: string - model: Model - provider: ProviderContext - message: UserMessage - request: { - system: string[] - messages: ModelMessage[] - tools: Record - toolChoice?: "auto" | "required" | "none" - params: { - temperature?: number - topP?: number - topK?: number - maxOutputTokens?: number - options: Record - } - headers: Record - } -} - -type LlmStreamWrapNext = ( - input: LlmStreamWrapInput, -) => AsyncIterable | Promise> - -type LlmStreamWrapHook = ( - input: LlmStreamWrapInput, - next: LlmStreamWrapNext, -) => AsyncIterable | Promise> -``` - -Expected semantics: - -1. OpenCode builds the final request object before calling the AI SDK. -2. OpenCode calls plugins as a nested chain. -3. The NeMo Flow plugin serializes the request, runs NeMo Flow - `llm_stream_execute`, applies request intercept results, then calls `next`. -4. OpenCode sends the final rewritten request to the provider. -5. Chunks flow back through the wrapper so NeMo Flow can emit stream start, - chunk, end, and error events. - -### Tool Execute Hook - -```ts -type ToolExecuteWrapInput = { - tool: string - sessionID: string - callID: string - args: unknown - source: "builtin" | "mcp" | "task" | "plugin" -} - -type ToolExecuteWrapNext = (input: ToolExecuteWrapInput) => Promise - -type ToolExecuteWrapHook = ( - input: ToolExecuteWrapInput, - next: ToolExecuteWrapNext, -) => Promise -``` - -Expected semantics: - -1. OpenCode validates tool input and constructs the tool execution context. -2. OpenCode calls the wrapper chain. -3. The NeMo Flow plugin runs `tool_call_execute`. -4. NeMo Flow request intercepts can rewrite `input.args`. -5. NeMo Flow execution intercepts can call `next`, skip `next`, retry, cache, or - replace the result. -6. OpenCode receives the final result and continues its normal message update - path. - -## Hook Composition - -OpenCode currently runs hooks sequentially in plugin load order. Around hooks -should preserve that simplicity: - -```ts -function composeWrapHooks(hooks, finalNext) { - return hooks.reduceRight( - (next, hook) => (input) => hook(input, next), - finalNext, - ) -} -``` - -No priority field is required for the first version. Load order is enough and -matches the rest of the plugin system. A priority field can be added later only -if OpenCode wants deterministic ordering independent of config order. - -## Implementation Breakdown - -### Phase 0: Use The Existing Patch As Reference Only - -The current NeMo Flow patch is useful for learning and validation, but it -should not be the production integration strategy. - -Use it to answer these questions: - -1. Which OpenCode events are needed for session/message hierarchy? -2. Which existing hooks provide agent/model/tool metadata? -3. Which ATOF/ATIF output can be produced without patching OpenCode? -4. Which exact behaviors remain impossible without around-style hooks? - -Do not add more NeMo Flow-specific code to OpenCode as the long-term path. - -### Shared Smoke Test Setup - -Use this setup for every phase demo. The goal is to show the integration as an -end user would run it, not as a patched OpenCode checkout. - -Assumptions: - -1. Node.js 20 or newer is installed. -2. OpenCode can reach at least one configured model provider. -3. `jq` is installed for checking JSON demo output. -4. The NeMo Flow OpenCode plugin package has been built or published. -5. The final package name is still open. The examples below use - `@nvidia/nemoflow-opencode-plugin` as a placeholder. - -Common commands: - -```bash -git clone https://github.com/NVIDIA/NeMo-Flow.git -cd NeMo-Flow - -npm install -g opencode-ai@latest -opencode --version - -export NEMO_FLOW_OPENCODE_PLUGIN="@nvidia/nemoflow-opencode-plugin" -export NEMO_FLOW_DEMO_DIR="$PWD/tmp/opencode-nemoflow-demo" - -rm -rf "$NEMO_FLOW_DEMO_DIR" -mkdir -p "$NEMO_FLOW_DEMO_DIR/.nemoflow" -cd "$NEMO_FLOW_DEMO_DIR" - -cat > opencode.json < opencode.disabled.json -mv opencode.disabled.json opencode.json -rm -f ./.nemoflow/* - -opencode run \ - --title "nemo-flow phase 2 disabled smoke" \ - "Reply with exactly: plugin disabled smoke." - -ls -la ./.nemoflow -mv opencode.enabled.json opencode.json -``` - -Expected disabled output: - -1. OpenCode still completes the run. -2. The NeMo Flow output files are absent or empty. - -Run the init-failure path only if the plugin exposes a demo failure switch: - -```bash -rm -f ./.nemoflow/* -NEMO_FLOW_OPENCODE_FORCE_INIT_FAILURE=1 opencode run \ - --title "nemo-flow phase 2 init failure smoke" \ - "Reply with exactly: init failure smoke." - -grep -i "failed\\|disabled\\|pass-through" ./.nemoflow/opencode-plugin.log -``` - -Expected failure output: - -1. OpenCode still completes the run. -2. The plugin log records one clear pass-through message. -3. No partial or corrupt ATIF/ATOF output is written. - -### Phase 3: Retire The Patch For Observability - -Once the observability plugin works: - -1. Stop treating the OpenCode patch as the observability integration path. -2. Remove OpenCode-specific NeMo Flow flags, config schema, and internal plugin - code from the OpenCode tree. -3. Update NeMo Flow docs to explain how to install and configure the OpenCode - plugin. -4. Keep the old patch only as temporary reference material if it is still useful - for the later intercept investigation. - -#### Smoke Test Guide - -This demo proves the third promised feature: the observability integration works -with a stock OpenCode install and does not depend on the NeMo Flow OpenCode -patch. - -Run from a fresh directory outside the NeMo Flow repository: - -```bash -export CLEAN_DEMO_DIR="$(mktemp -d)" -cd "$CLEAN_DEMO_DIR" -mkdir -p .nemoflow - -npm install -g opencode-ai@latest -opencode --version | tee ./.nemoflow/opencode-version.txt -which opencode | tee ./.nemoflow/opencode-path.txt - -cat > opencode.json < Date: Fri, 8 May 2026 15:31:25 -0400 Subject: [PATCH 5/7] docs: add OpenCode plugin docstrings Signed-off-by: Binfeng Xu --- integrations/opencode-plugin/server.js | 90 ++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/integrations/opencode-plugin/server.js b/integrations/opencode-plugin/server.js index ea4c77db..6336889c 100644 --- a/integrations/opencode-plugin/server.js +++ b/integrations/opencode-plugin/server.js @@ -21,9 +21,15 @@ const RELEVANT_EVENTS = new Set([ "message.part.removed", ]) +/** + * Create the plugin logger. + */ function createLogger(logPath) { const seen = new Set() + /** + * Write one diagnostic record to the configured log destination. + */ async function write(level, message, extra) { const record = { timestamp: new Date().toISOString(), @@ -56,16 +62,25 @@ function createLogger(logPath) { } } +/** + * Ensure the parent directory for an output file exists. + */ async function ensureParentDir(filePath) { await fs.mkdir(path.dirname(filePath), { recursive: true }) } +/** + * Resolve a plugin output path relative to the OpenCode project directory. + */ function resolveOutputPath(baseDir, value) { if (typeof value !== "string" || value.trim() === "") return undefined if (path.isAbsolute(value)) return value return path.resolve(baseDir, value) } +/** + * Normalize OpenCode plugin options into concrete runtime settings. + */ function normalizeOptions(input, options = {}) { const baseDir = input?.directory ?? process.cwd() return { @@ -76,6 +91,9 @@ function normalizeOptions(input, options = {}) { } } +/** + * Convert arbitrary OpenCode hook payloads into JSON-safe data. + */ function toJsonSafe(value) { if (value === undefined) return null if (value instanceof Error) { @@ -108,6 +126,9 @@ function toJsonSafe(value) { } } +/** + * Format OpenCode model metadata as a stable provider/model string. + */ function modelName(model) { if (!model) return undefined const provider = model.providerID ?? model.provider?.id @@ -117,6 +138,9 @@ function modelName(model) { return undefined } +/** + * Read the OpenCode agent name from hook input or event metadata. + */ function agentName(input, fallback = "opencode") { if (typeof input?.agent === "string" && input.agent) return input.agent if (typeof input?.message?.agent === "string" && input.message.agent) return input.message.agent @@ -124,11 +148,17 @@ function agentName(input, fallback = "opencode") { return fallback } +/** + * Read the OpenCode session ID from a bus event payload. + */ function eventSessionID(event) { const props = event?.properties return props?.sessionID ?? props?.info?.id } +/** + * Build metadata attached to the NeMo Flow session scope. + */ function inputSessionMetadata(sessionID, state) { return { source: "opencode", @@ -138,6 +168,9 @@ function inputSessionMetadata(sessionID, state) { } } +/** + * Build common metadata for OpenCode-derived NeMo Flow marks. + */ function eventMetadata(session, extra = {}) { return { agent: session?.agent, @@ -146,6 +179,9 @@ function eventMetadata(session, extra = {}) { } } +/** + * Decide whether an OpenCode event should flush the ATIF trajectory. + */ function shouldFlushEvent(event) { if (!event) return false if (event.type === "session.deleted" || event.type === "session.idle") return true @@ -153,6 +189,9 @@ function shouldFlushEvent(event) { return event.properties?.status?.type === "idle" } +/** + * Create the NeMo Flow adapter behind the OpenCode plugin hooks. + */ function createNemoFlowAdapter(lib, options, logger) { const sessions = new Map() const recentFlushes = new Map() @@ -161,6 +200,9 @@ function createNemoFlowAdapter(lib, options, logger) { let atofDeregisterTimer let closed = false + /** + * Register the process-local ATOF JSONL subscriber on first use. + */ function registerAtOfJsonlExporter() { if (atofDeregisterTimer) { clearTimeout(atofDeregisterTimer) @@ -175,6 +217,9 @@ function createNemoFlowAdapter(lib, options, logger) { void logger.info("registered ATOF JSONL exporter", { path: options.atofPath }) } + /** + * Deregister the ATOF JSONL subscriber after the last session closes. + */ function deregisterAtOfJsonlExporter() { if (atofDeregisterTimer) { clearTimeout(atofDeregisterTimer) @@ -190,6 +235,9 @@ function createNemoFlowAdapter(lib, options, logger) { } } + /** + * Delay ATOF subscriber cleanup so adjacent events can still flush. + */ function scheduleAtOfJsonlExporterDeregister() { if (!atofSubscriberName || atofDeregisterTimer) return atofDeregisterTimer = setTimeout(() => { @@ -197,6 +245,9 @@ function createNemoFlowAdapter(lib, options, logger) { }, 250) } + /** + * Run a callback with the session scope stack active when supported. + */ function withStack(session, callback) { if (!session.stack || typeof lib.setThreadScopeStack !== "function") return callback() const previous = typeof lib.currentScopeStack === "function" ? lib.currentScopeStack() : undefined @@ -208,6 +259,9 @@ function createNemoFlowAdapter(lib, options, logger) { } } + /** + * Create or update the NeMo Flow session state for an OpenCode session. + */ function ensureSession(sessionID, metadata = {}) { if (!sessionID) return undefined registerAtOfJsonlExporter() @@ -252,6 +306,9 @@ function createNemoFlowAdapter(lib, options, logger) { return session } + /** + * Emit an OpenCode milestone as a NeMo Flow mark event. + */ function emitMark(session, name, data, metadata = {}) { if (!session?.scope) return lib.event( @@ -267,6 +324,9 @@ function createNemoFlowAdapter(lib, options, logger) { ) } + /** + * Write all collected ATIF trajectories to the configured file. + */ function writeAtifFile() { if (!options.atifPath) return const payload = trajectories.length === 1 ? trajectories[0] : { trajectories } @@ -274,6 +334,9 @@ function createNemoFlowAdapter(lib, options, logger) { fsSync.writeFileSync(options.atifPath, JSON.stringify(payload, null, 2)) } + /** + * Close an OpenCode session scope and export its trajectory. + */ function flushSession(sessionID, reason) { const session = sessions.get(sessionID) if (!session) return @@ -318,6 +381,9 @@ function createNemoFlowAdapter(lib, options, logger) { } return { + /** + * Record OpenCode configuration context for diagnostics. + */ async recordConfig(config) { if (closed) return await logger.info("observed OpenCode config", { @@ -326,6 +392,9 @@ function createNemoFlowAdapter(lib, options, logger) { }) }, + /** + * Record relevant OpenCode bus events as NeMo Flow marks. + */ async recordEvent(event) { if (closed || !RELEVANT_EVENTS.has(event?.type)) return const sessionID = eventSessionID(event) @@ -352,6 +421,9 @@ function createNemoFlowAdapter(lib, options, logger) { } }, + /** + * Record user message metadata for the current OpenCode turn. + */ async recordChatMessage(input, output) { if (closed) return const session = ensureSession(input.sessionID, { @@ -371,6 +443,9 @@ function createNemoFlowAdapter(lib, options, logger) { ) }, + /** + * Record model and provider metadata near the LLM request boundary. + */ async recordChatParams(input, output) { if (closed) return const session = ensureSession(input.sessionID, { @@ -394,6 +469,9 @@ function createNemoFlowAdapter(lib, options, logger) { ) }, + /** + * Start a NeMo Flow tool span for an OpenCode tool call. + */ async recordToolBefore(input, output) { if (closed) return const session = ensureSession(input.sessionID) @@ -412,6 +490,9 @@ function createNemoFlowAdapter(lib, options, logger) { session.pendingTools.set(input.callID, { handle, callID: input.callID, tool: input.tool, args }) }, + /** + * Finish a successful NeMo Flow tool span for an OpenCode tool call. + */ async recordToolAfter(input, output) { if (closed) return const session = ensureSession(input.sessionID) @@ -445,6 +526,9 @@ function createNemoFlowAdapter(lib, options, logger) { session.pendingTools.delete(input.callID) }, + /** + * Flush open sessions and unregister exporters during plugin shutdown. + */ async close() { closed = true for (const sessionID of [...sessions.keys()]) { @@ -455,6 +539,9 @@ function createNemoFlowAdapter(lib, options, logger) { } } +/** + * Load the default NeMo Flow Node.js runtime. + */ async function loadDefaultRuntime() { if (process.env.NEMO_FLOW_OPENCODE_FORCE_INIT_FAILURE === "1") { throw new Error("forced initialization failure") @@ -463,6 +550,9 @@ async function loadDefaultRuntime() { return mod.default ?? mod } +/** + * Create the OpenCode server plugin entrypoint. + */ export function createServerPlugin({ loadRuntime = loadDefaultRuntime } = {}) { return async function server(input, options) { const normalized = normalizeOptions(input, options) From c9d9da2ffb65478ba5b16db8b818e46d9a4d0359 Mon Sep 17 00:00:00 2001 From: Binfeng Xu Date: Tue, 12 May 2026 00:04:11 -0400 Subject: [PATCH 6/7] Update integrations/opencode-plugin/package.json Co-authored-by: Will Killian <2007799+willkill07@users.noreply.github.com> Signed-off-by: Binfeng Xu --- integrations/opencode-plugin/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrations/opencode-plugin/package.json b/integrations/opencode-plugin/package.json index e0281b3f..42020374 100644 --- a/integrations/opencode-plugin/package.json +++ b/integrations/opencode-plugin/package.json @@ -1,5 +1,5 @@ { - "name": "@nvidia/nemoflow-opencode-plugin", + "name": "nemo-flow-opencode", "version": "0.2.0", "description": "OpenCode server plugin that exports NeMo Flow observability data.", "type": "module", From 373d2f578df69f77c55bfb626b345acb05b11529 Mon Sep 17 00:00:00 2001 From: Binfeng Xu Date: Tue, 12 May 2026 00:04:41 -0400 Subject: [PATCH 7/7] Update docs/integrate-frameworks/opencode.md Co-authored-by: Will Killian <2007799+willkill07@users.noreply.github.com> Signed-off-by: Binfeng Xu --- docs/integrate-frameworks/opencode.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/integrate-frameworks/opencode.md b/docs/integrate-frameworks/opencode.md index 9d7fe200..107d9c2c 100644 --- a/docs/integrate-frameworks/opencode.md +++ b/docs/integrate-frameworks/opencode.md @@ -77,7 +77,7 @@ Create or update `opencode.json` in the OpenCode project directory: { "plugin": [ [ - "file:///absolute/path/to/NeMo-Flow/integrations/opencode-plugin", + "nemo-flow-opencode", { "enabled": true, "atofPath": "./.nemoflow/opencode.atof.jsonl",