From d33301e34a881654f832aea8efc5f6d5ab08a347 Mon Sep 17 00:00:00 2001 From: Binfeng Xu Date: Fri, 8 May 2026 11:54:48 -0400 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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 06/11] 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 07/11] 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", From f1884891d0ca9ae13f287d412776ea4a4d8add65 Mon Sep 17 00:00:00 2001 From: Binfeng Xu Date: Fri, 15 May 2026 00:49:07 -0400 Subject: [PATCH 08/11] opencode observability plugin refactor --- docs/integrate-frameworks/opencode.md | 164 ++- integrations/opencode-plugin/README.md | 76 -- .../opencode-plugin/package-lock.json | 1185 ----------------- integrations/opencode/README.md | 86 ++ .../package.json | 17 +- .../{opencode-plugin => opencode}/server.js | 338 +++-- .../test/server.test.mjs | 294 ++-- package-lock.json | 337 ++++- package.json | 3 +- 9 files changed, 982 insertions(+), 1518 deletions(-) delete mode 100644 integrations/opencode-plugin/README.md delete mode 100644 integrations/opencode-plugin/package-lock.json create mode 100644 integrations/opencode/README.md rename integrations/{opencode-plugin => opencode}/package.json (64%) rename integrations/{opencode-plugin => opencode}/server.js (62%) rename integrations/{opencode-plugin => opencode}/test/server.test.mjs (51%) diff --git a/docs/integrate-frameworks/opencode.md b/docs/integrate-frameworks/opencode.md index 107d9c2c..6a36539a 100644 --- a/docs/integrate-frameworks/opencode.md +++ b/docs/integrate-frameworks/opencode.md @@ -11,13 +11,18 @@ OpenCode checkout. Use this plugin when you want NeMo Flow observability for OpenCode sessions, messages, LLM request metadata, successful tool calls, and session errors. +The OpenCode plugin maps those hook payloads into NeMo Flow scopes and events. +The generic NeMo Flow `observability` plugin config controls ATOF, ATIF, +OpenTelemetry, and OpenInference export. ## 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. +interface or `opencode run`. + +The diagram shows the split between OpenCode hook capture and generic NeMo Flow +export configuration. ```{mermaid} flowchart LR @@ -25,21 +30,28 @@ flowchart LR OpenCode[Stock OpenCode] Plugin[NeMo Flow
OpenCode plugin] Runtime[NeMo Flow
Node.js binding] + Host[Generic NeMo Flow
plugin host] ATOF[(ATOF JSONL)] ATIF[(ATIF JSON)] + OTLP[(OTLP traces)] 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 + Plugin -->|plugins config| Host + Host -->|observability component| Runtime + Runtime -->|raw events| ATOF + Runtime -->|agent trajectories| ATIF + Runtime -->|optional traces| OTLP class User blue-lightest; class OpenCode green-lightest; class Plugin purple-lightest; + class Host purple-light; class Runtime green-light; class ATOF yellow-lightest; class ATIF yellow-lightest; + class OTLP yellow-lightest; ``` The plugin is passive. It records observability output but does not rewrite @@ -65,37 +77,69 @@ 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. +When the plugin package is published, use `nemo-flow-opencode` in the OpenCode +config instead of the local file URL. ## Configure OpenCode -Create or update `opencode.json` in the OpenCode project directory: +Create or update `opencode.json` in the OpenCode project directory. The +top-level OpenCode plugin fields use JavaScript-style names such as `logPath`. +The nested `plugins` object is the generic NeMo Flow plugin config, so its +field names are `snake_case` in every language. ```json { "plugin": [ [ - "nemo-flow-opencode", + "file:///absolute/path/to/NeMo-Flow/integrations/opencode", { "enabled": true, - "atofPath": "./.nemoflow/opencode.atof.jsonl", - "atifPath": "./.nemoflow/opencode.atif.json", - "logPath": "./.nemoflow/opencode-plugin.log" + "logPath": "./.nemoflow/opencode-plugin.log", + "plugins": { + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": true, + "config": { + "version": 1, + "atof": { + "enabled": true, + "output_directory": "./.nemoflow", + "filename": "opencode.atof.jsonl", + "mode": "overwrite" + }, + "atif": { + "enabled": true, + "agent_name": "opencode", + "output_directory": "./.nemoflow", + "filename_template": "opencode-{session_id}.atif.json" + } + } + } + ] + } } ] ] } ``` -The paths are resolved relative to the OpenCode project directory. If -`nemo-flow-node` is missing or cannot initialize, the plugin logs one warning +The OpenCode plugin resolves `logPath` and observability `output_directory` +values relative to the OpenCode project directory. Other generic plugin fields +keep NeMo Flow's standard behavior. If `nemo-flow-node` is missing, plugin +validation fails, or plugin initialization fails, the plugin logs one warning and returns no hooks, so OpenCode continues in pass-through mode. -## Run the Demo +The ATIF filename placeholder `{session_id}` is the NeMo Flow top-level agent +scope UUID. The OpenCode session ID is still recorded in event metadata. + +For the complete `observability` component schema, see +[Configure the Observability Plugin](../export-observability-data/observability-plugin.md). -Use this demo when you want to show the integration end to end. It uses a +## Run A Local Smoke + +Use this smoke when you want to check the integration end to end. It uses a source checkout plugin path because the package is not published yet. ```bash @@ -110,12 +154,34 @@ cat > opencode.json <>OC: Start OpenCode in a project - OC->>Plug: config(input) - Plug->>Files: Write plugin diagnostics + OC->>Plug: server(input, options) + Plug->>Host: Validate and initialize plugins config Dev->>OC: Send a prompt or run a task OC->>Plug: chat.message and chat.params Plug->>NF: Emit session and LLM request marks @@ -207,14 +273,15 @@ sequenceDiagram 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 + Plug->>NF: Close session scope 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. +The plugin should not change OpenCode behavior when observability is disabled, +when the NeMo Flow runtime is unavailable, or when the generic plugin config is +invalid. Disable the plugin: @@ -228,7 +295,7 @@ 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 +test -z "$(find ./.nemoflow -name 'opencode-*.atif.json' -print -quit)" mv opencode.enabled.json opencode.json ``` @@ -245,23 +312,6 @@ 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 diff --git a/integrations/opencode-plugin/README.md b/integrations/opencode-plugin/README.md deleted file mode 100644 index 1eea1333..00000000 --- a/integrations/opencode-plugin/README.md +++ /dev/null @@ -1,76 +0,0 @@ - - -# 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 deleted file mode 100644 index eba44d2e..00000000 --- a/integrations/opencode-plugin/package-lock.json +++ /dev/null @@ -1,1185 +0,0 @@ -{ - "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/README.md b/integrations/opencode/README.md new file mode 100644 index 00000000..d817dd13 --- /dev/null +++ b/integrations/opencode/README.md @@ -0,0 +1,86 @@ + + +# 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, 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", + { + "enabled": true, + "logPath": "./.nemoflow/opencode-plugin.log", + "plugins": { + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": true, + "config": { + "version": 1, + "atof": { + "enabled": true, + "output_directory": "./.nemoflow", + "filename": "opencode.atof.jsonl" + }, + "atif": { + "enabled": true, + "agent_name": "opencode", + "output_directory": "./.nemoflow", + "filename_template": "opencode-{session_id}.atif.json" + } + } + } + ] + } + } + ] + ] +} +``` + +When this package is published, replace the file URL with the package name +`nemo-flow-opencode`. + +The package loads `nemo-flow-node` and `nemo-flow-node/plugin` 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 plugin APIs through the +`@opencode-ai/plugin` peer dependency. It uses public OpenCode server plugin +hooks available in `@opencode-ai/plugin` `1.14.40` and newer. + +## Output + +Output is controlled by the generic NeMo Flow `plugins` config. Configure the +built-in `observability` component to write: + +- ATOF JSONL events with `plugins.components[].config.atof`. +- ATIF trajectory files with `plugins.components[].config.atif`. +- Optional OpenTelemetry or OpenInference traces with + `plugins.components[].config.opentelemetry` or `openinference`. +- JSONL plugin diagnostics with the OpenCode wrapper `logPath` field. + +## 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.json b/integrations/opencode/package.json similarity index 64% rename from integrations/opencode-plugin/package.json rename to integrations/opencode/package.json index 42020374..7a911f63 100644 --- a/integrations/opencode-plugin/package.json +++ b/integrations/opencode/package.json @@ -1,17 +1,15 @@ { "name": "nemo-flow-opencode", "version": "0.2.0", - "description": "OpenCode server plugin that exports NeMo Flow observability data.", + "description": "OpenCode server plugin that maps OpenCode activity into NeMo Flow observability.", "type": "module", "main": "./server.js", "exports": { ".": { - "default": "./server.js", - "import": "./server.js" + "default": "./server.js" }, "./server": { - "default": "./server.js", - "import": "./server.js" + "default": "./server.js" } }, "files": [ @@ -29,22 +27,21 @@ "atof", "plugin" ], - "homepage": "https://github.com/NVIDIA/NeMo-Flow#readme", + "homepage": "https://github.com/NVIDIA/NeMo-Flow/blob/main/docs/integrate-frameworks/opencode.md", "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" + "directory": "integrations/opencode" }, "license": "Apache-2.0", "engines": { - "node": ">=20.0.0", - "opencode": ">=1.14.40" + "node": ">=20.0.0" }, "dependencies": { - "nemo-flow-node": "file:../../crates/node" + "nemo-flow-node": ">0.1.0 <1.0.0" }, "peerDependencies": { "@opencode-ai/plugin": ">=1.14.40" diff --git a/integrations/opencode-plugin/server.js b/integrations/opencode/server.js similarity index 62% rename from integrations/opencode-plugin/server.js rename to integrations/opencode/server.js index 6336889c..1440892d 100644 --- a/integrations/opencode-plugin/server.js +++ b/integrations/opencode/server.js @@ -1,12 +1,15 @@ // 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 PLUGIN_ID = "nemo-flow-opencode" +const DEFAULT_PLUGIN_HOST_CONFIG = Object.freeze({ + version: 1, + components: Object.freeze([]), +}) +const RECENT_FLUSH_TTL_MS = 2000 const RELEVANT_EVENTS = new Set([ "session.created", "session.updated", @@ -78,17 +81,125 @@ function resolveOutputPath(baseDir, value) { return path.resolve(baseDir, value) } +/** + * Resolve an output directory inside generic observability plugin config. + */ +function resolveOutputDirectory(baseDir, value) { + if (typeof value !== "string" || value.trim() === "") return value + 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() + const rawOptions = options ?? {} + rejectRemovedOption(rawOptions, "atofPath", "configure plugins.components[].config.atof instead") + rejectRemovedOption(rawOptions, "atifPath", "configure plugins.components[].config.atif instead") + 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"), + enabled: rawOptions.enabled !== false, + plugins: normalizePluginHostConfig(baseDir, rawOptions.plugins), + logPath: resolveOutputPath(baseDir, rawOptions.logPath ?? "./.nemoflow/opencode-plugin.log"), + } +} + +/** + * Normalize the embedded generic NeMo Flow plugin-host configuration. + */ +function normalizePluginHostConfig(baseDir, value) { + if (value === undefined) { + return clonePluginHostConfig(DEFAULT_PLUGIN_HOST_CONFIG) + } + + const raw = asRecord(value, "plugins", false) + const version = optionalNumber(raw.version, "plugins.version") ?? 1 + const components = raw.components === undefined ? [] : raw.components + + if (!Array.isArray(components)) { + throw new Error("plugins.components must be an array") + } + + return { + ...raw, + version, + components: components.map((component, index) => + normalizePluginComponent(baseDir, component, `plugins.components[${index}]`), + ), + } +} + +/** + * Clone the mutable generic plugin config before giving it to the runtime. + */ +function clonePluginHostConfig(config) { + return { + ...config, + components: [...config.components], + } +} + +/** + * Normalize path-bearing sections on the built-in observability component. + */ +function normalizePluginComponent(baseDir, value, fieldPath) { + const component = asRecord(value, fieldPath, false) + const normalized = { ...component } + + if (component.kind !== "observability" || component.config === undefined) { + return normalized + } + + normalized.config = normalizeObservabilityConfig(baseDir, asRecord(component.config, `${fieldPath}.config`, false)) + return normalized +} + +/** + * Keep OpenCode project-relative paths ergonomic while preserving NeMo Flow's + * generic plugin config shape. + */ +function normalizeObservabilityConfig(baseDir, config) { + const normalized = { ...config } + for (const sectionName of ["atof", "atif"]) { + if (normalized[sectionName] === undefined) continue + const section = asRecord(normalized[sectionName], `observability.${sectionName}`, false) + normalized[sectionName] = { + ...section, + output_directory: resolveOutputDirectory(baseDir, section.output_directory), + } } + return normalized +} + +/** + * Reject exporter options that were replaced by the generic plugin config. + */ +function rejectRemovedOption(options, name, hint) { + if (Object.prototype.hasOwnProperty.call(options, name)) { + throw new Error(`${name} was removed; ${hint}`) + } +} + +/** + * Require an object config section, optionally treating undefined as empty. + */ +function asRecord(value, fieldPath, optional) { + if (value === undefined && optional) return {} + if (value !== null && typeof value === "object" && !Array.isArray(value)) return value + throw new Error(`${fieldPath} must be an object`) +} + +/** + * Parse an optional finite number. + */ +function optionalNumber(value, fieldPath) { + if (value === undefined) return undefined + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${fieldPath} must be a finite number`) + } + return value } /** @@ -189,60 +300,90 @@ function shouldFlushEvent(event) { return event.properties?.status?.type === "idle" } +/** + * Log plugin-host validation or activation diagnostics. + */ +async function logDiagnostics(logger, diagnostics = []) { + for (const diagnostic of diagnostics) { + const prefix = diagnostic.component ? `${diagnostic.component}: ` : "" + const message = `${prefix}${diagnostic.code}: ${diagnostic.message}` + if (diagnostic.level === "error") { + await logger.warn(message, diagnostic) + } else { + await logger.info(message, diagnostic) + } + } +} + +/** + * Return true when a plugin-host report contains error diagnostics. + */ +function hasErrorDiagnostics(report) { + return report?.diagnostics?.some((diagnostic) => diagnostic.level === "error") === true +} + +/** + * Validate and activate NeMo Flow's generic plugin-host config. + */ +async function initializePluginHost(pluginHost, config, logger) { + const validationReport = pluginHost.validate(config) + await logDiagnostics(logger, validationReport.diagnostics) + if (hasErrorDiagnostics(validationReport)) { + throw new Error("NeMo Flow plugin host config validation failed") + } + + const activationReport = await pluginHost.initialize(config) + await logDiagnostics(logger, activationReport.diagnostics) + if (hasErrorDiagnostics(activationReport)) { + await logger.warn("NeMo Flow plugin host initialized with error diagnostics") + } +} + +/** + * Summarize the active generic plugin config for diagnostics. + */ +function pluginConfigSummary(config) { + const components = Array.isArray(config?.components) ? config.components : [] + return { + version: config?.version, + components: components.map((component) => ({ + kind: component?.kind, + enabled: component?.enabled !== false, + })), + } +} + +/** + * Convert thrown values into stable log records. + */ +function toMessage(error) { + return error instanceof Error ? error.message : String(error) +} + /** * Create the NeMo Flow adapter behind the OpenCode plugin hooks. */ -function createNemoFlowAdapter(lib, options, logger) { +function createNemoFlowAdapter(lib, pluginHost, logger) { const sessions = new Map() const recentFlushes = new Map() - const trajectories = [] - let atofSubscriberName - let atofDeregisterTimer let closed = false /** - * Register the process-local ATOF JSONL subscriber on first use. + * Prune duplicate-flush suppression state so it cannot grow indefinitely. */ - function registerAtOfJsonlExporter() { - if (atofDeregisterTimer) { - clearTimeout(atofDeregisterTimer) - atofDeregisterTimer = undefined + function pruneRecentFlushes(now = Date.now()) { + for (const [sessionID, timestamp] of recentFlushes) { + if (now - timestamp > RECENT_FLUSH_TTL_MS) recentFlushes.delete(sessionID) } - 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 }) } /** - * Deregister the ATOF JSONL subscriber after the last session closes. + * Return true when a just-closed session receives a duplicate idle/delete event. */ - 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 - } - } - - /** - * Delay ATOF subscriber cleanup so adjacent events can still flush. - */ - function scheduleAtOfJsonlExporterDeregister() { - if (!atofSubscriberName || atofDeregisterTimer) return - atofDeregisterTimer = setTimeout(() => { - deregisterAtOfJsonlExporter() - }, 250) + function wasRecentlyFlushed(sessionID) { + pruneRecentFlushes() + const recentFlushAt = recentFlushes.get(sessionID) + return recentFlushAt !== undefined && Date.now() - recentFlushAt <= RECENT_FLUSH_TTL_MS } /** @@ -255,7 +396,7 @@ function createNemoFlowAdapter(lib, options, logger) { try { return callback() } finally { - if (previous) lib.setThreadScopeStack(previous) + if (previous !== undefined) lib.setThreadScopeStack(previous) } } @@ -264,7 +405,6 @@ function createNemoFlowAdapter(lib, options, logger) { */ function ensureSession(sessionID, metadata = {}) { if (!sessionID) return undefined - registerAtOfJsonlExporter() let session = sessions.get(sessionID) if (session) { @@ -279,13 +419,9 @@ function createNemoFlowAdapter(lib, options, logger) { 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", @@ -325,22 +461,13 @@ 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 } - fsSync.mkdirSync(path.dirname(options.atifPath), { recursive: true }) - fsSync.writeFileSync(options.atifPath, JSON.stringify(payload, null, 2)) - } - - /** - * Close an OpenCode session scope and export its trajectory. + * Close an OpenCode session scope so generic observability plugins can flush. */ function flushSession(sessionID, reason) { const session = sessions.get(sessionID) if (!session) return recentFlushes.set(sessionID, Date.now()) + pruneRecentFlushes() emitMark(session, "opencode.session.flush", { sessionID, reason }) for (const [key, tool] of session.pendingTools) { try { @@ -364,20 +491,7 @@ function createNemoFlowAdapter(lib, options, logger) { } } - 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 { @@ -399,8 +513,7 @@ function createNemoFlowAdapter(lib, options, logger) { 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 + if (shouldFlushEvent(event) && wasRecentlyFlushed(sessionID)) return const props = event.properties ?? {} const session = ensureSession(sessionID, { agent: agentName(props.info, undefined), @@ -534,29 +647,65 @@ function createNemoFlowAdapter(lib, options, logger) { for (const sessionID of [...sessions.keys()]) { flushSession(sessionID, "plugin-close") } - deregisterAtOfJsonlExporter() + try { + pluginHost.clear() + } catch (error) { + await logger.warnOnce("plugin-host-clear", "failed to clear NeMo Flow plugin host", error) + } }, } } /** - * Load the default NeMo Flow Node.js runtime. + * Load the default NeMo Flow Node.js runtime and plugin host. */ -async function loadDefaultRuntime() { +async function loadDefaultModules() { 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 + const [runtimeModule, pluginHostModule] = await Promise.all([ + import("nemo-flow-node"), + import("nemo-flow-node/plugin"), + ]) + return { + lib: runtimeModule.default ?? runtimeModule, + pluginHost: pluginHostModule.default ?? pluginHostModule, + } +} + +/** + * Register process cleanup for OpenCode runs without an explicit close hook. + */ +function registerBeforeExitCleanup(close, logger) { + const listener = () => { + void close().catch((error) => { + void logger.warnOnce("before-exit-cleanup", "failed to clean up NeMo Flow OpenCode plugin", error) + }) + } + process.on("beforeExit", listener) + return () => process.removeListener("beforeExit", listener) } /** * Create the OpenCode server plugin entrypoint. */ -export function createServerPlugin({ loadRuntime = loadDefaultRuntime } = {}) { +export function createServerPlugin({ + loadModules = loadDefaultModules, + registerCleanup = registerBeforeExitCleanup, +} = {}) { return async function server(input, options) { - const normalized = normalizeOptions(input, options) - const logger = createLogger(normalized.logPath) + let normalized + let logger + + try { + normalized = normalizeOptions(input, options) + logger = createLogger(normalized.logPath) + } catch (error) { + const baseDir = input?.directory ?? process.cwd() + logger = createLogger(resolveOutputPath(baseDir, options?.logPath ?? "./.nemoflow/opencode-plugin.log")) + await logger.warnOnce("config-invalid", "NeMo Flow OpenCode plugin config invalid; running pass-through", error) + return {} + } if (!normalized.enabled) { await logger.warnOnce("disabled", "NeMo Flow OpenCode plugin disabled by configuration") @@ -565,16 +714,21 @@ export function createServerPlugin({ loadRuntime = loadDefaultRuntime } = {}) { let adapter try { - const lib = await loadRuntime() - adapter = createNemoFlowAdapter(lib, normalized, logger) + const { lib, pluginHost } = await loadModules() + await initializePluginHost(pluginHost, normalized.plugins, logger) + adapter = createNemoFlowAdapter(lib, pluginHost, logger) + let unregisterCleanup + unregisterCleanup = registerCleanup(async () => { + unregisterCleanup?.() + await adapter.close() + }, logger) await logger.info("initialized NeMo Flow OpenCode plugin", { - atofPath: normalized.atofPath, - atifPath: normalized.atifPath, + plugins: pluginConfigSummary(normalized.plugins), }) } catch (error) { await logger.warnOnce( "init-failed", - "NeMo Flow runtime unavailable; OpenCode plugin is running pass-through", + `NeMo Flow runtime unavailable or misconfigured; OpenCode plugin is running pass-through: ${toMessage(error)}`, error, ) return {} diff --git a/integrations/opencode-plugin/test/server.test.mjs b/integrations/opencode/test/server.test.mjs similarity index 51% rename from integrations/opencode-plugin/test/server.test.mjs rename to integrations/opencode/test/server.test.mjs index d254a546..188f5904 100644 --- a/integrations/opencode-plugin/test/server.test.mjs +++ b/integrations/opencode/test/server.test.mjs @@ -10,60 +10,22 @@ import { describe, it } from "node:test" import { createServerPlugin } from "../server.js" function createFakeRuntime() { - const subscribers = new Map() + const events = [] 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, - }) - } - } + let activeStack = { id: "current" } return { + events, 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" } + return activeStack + }, + setThreadScopeStack(stack) { + activeStack = stack }, - setThreadScopeStack(_stack) {}, pushScope(name, scopeType, _parent, attributes, data, metadata, input) { const handle = { uuid: `scope-${++counter}`, @@ -71,7 +33,7 @@ function createFakeRuntime() { scopeType, attributes, } - emit({ + events.push({ kind: "scope", category: "agent", scope_category: "start", @@ -84,7 +46,7 @@ function createFakeRuntime() { return handle }, popScope(handle, output) { - emit({ + events.push({ kind: "scope", category: "agent", scope_category: "end", @@ -94,7 +56,7 @@ function createFakeRuntime() { }) }, event(name, handle, data, metadata) { - emit({ + events.push({ kind: "mark", uuid: `mark-${++counter}`, parent_uuid: handle?.uuid, @@ -103,14 +65,14 @@ function createFakeRuntime() { metadata, }) }, - toolCall(name, args, handle, attributes, data, metadata, toolCallID) { + toolCall(name, args, handle, _attributes, data, metadata, toolCallID) { const tool = { uuid: `tool-${++counter}`, name, parentUuid: handle?.uuid, toolCallID, } - emit({ + events.push({ kind: "scope", category: "tool", scope_category: "start", @@ -123,7 +85,7 @@ function createFakeRuntime() { return tool }, toolCallEnd(handle, result, data, metadata) { - emit({ + events.push({ kind: "scope", category: "tool", scope_category: "end", @@ -136,33 +98,104 @@ function createFakeRuntime() { } } +function createFakePluginHost({ validateDiagnostics = [], initializeDiagnostics = [] } = {}) { + return { + validateCalls: [], + initializeCalls: [], + clearCalls: 0, + validate(config) { + this.validateCalls.push(config) + return { diagnostics: validateDiagnostics } + }, + async initialize(config) { + this.initializeCalls.push(config) + return { diagnostics: initializeDiagnostics } + }, + clear() { + this.clearCalls += 1 + }, + } +} + +function createHarness(params = {}) { + const runtime = params.runtime ?? createFakeRuntime() + const pluginHost = params.pluginHost ?? createFakePluginHost(params) + let cleanup + const server = createServerPlugin({ + loadModules: async () => { + if (params.loadError) throw params.loadError + return { lib: runtime, pluginHost } + }, + registerCleanup(close) { + cleanup = close + return () => { + if (cleanup === close) cleanup = undefined + } + }, + }) + + return { + runtime, + pluginHost, + server, + cleanup: async () => cleanup?.(), + } +} + +function pluginConfig() { + return { + version: 1, + components: [ + { + kind: "observability", + enabled: true, + config: { + version: 1, + atof: { + enabled: true, + output_directory: "./.nemoflow", + filename: "opencode.atof.jsonl", + }, + atif: { + enabled: true, + agent_name: "opencode", + output_directory: "./.nemoflow", + filename_template: "opencode-{session_id}.atif.json", + }, + }, + }, + ], + } +} + async function makeTempDir() { - return fs.mkdtemp(path.join(os.tmpdir(), "nemo-flow-opencode-plugin-")) + return fs.mkdtemp(path.join(os.tmpdir(), "nemo-flow-opencode-")) } -async function readJsonl(filePath) { - const content = await fs.readFile(filePath, "utf8") - return content - .trim() - .split("\n") - .filter(Boolean) - .map((line) => JSON.parse(line)) +function eventNames(events) { + return events.map((event) => event.name).filter(Boolean) } describe("NeMo Flow OpenCode plugin", () => { - it("records OpenCode hooks to ATOF and flushes ATIF on idle", async () => { + it("initializes generic plugin config and records OpenCode hooks until idle", async () => { const dir = await makeTempDir() - const server = createServerPlugin({ loadRuntime: async () => createFakeRuntime() }) + const { runtime, pluginHost, server } = createHarness() const hooks = await server( { directory: dir }, { enabled: true, - atofPath: "./.nemoflow/opencode.atof.jsonl", - atifPath: "./.nemoflow/opencode.atif.json", logPath: "./.nemoflow/opencode-plugin.log", + plugins: pluginConfig(), }, ) + const expectedOutputDirectory = path.join(dir, ".nemoflow") + assert.equal(pluginHost.validateCalls.length, 1) + assert.equal(pluginHost.initializeCalls.length, 1) + assert.equal(pluginHost.validateCalls[0].components[0].config.atof.output_directory, expectedOutputDirectory) + assert.equal(pluginHost.validateCalls[0].components[0].config.atif.output_directory, expectedOutputDirectory) + assert.deepEqual(pluginHost.initializeCalls[0], pluginHost.validateCalls[0]) + await hooks.config?.({ model: "test-provider/test-model", agent: { build: {} } }) await hooks["chat.message"]?.( { @@ -201,29 +234,32 @@ describe("NeMo Flow OpenCode plugin", () => { properties: { sessionID: "ses_1", status: { type: "idle" } }, }, }) + await hooks.event?.({ + event: { + id: "evt_1_duplicate", + 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")) + const names = eventNames(runtime.events) + assert.ok(names.includes("opencode.chat.message")) + assert.ok(names.includes("opencode.llm.request")) + assert.equal(names.filter((name) => name === "opencode.session.flush").length, 1) + assert.equal(runtime.events.filter((event) => event.category === "tool" && event.scope_category === "start").length, 1) + assert.equal(runtime.events.filter((event) => event.category === "tool" && event.scope_category === "end").length, 1) + assert.equal(runtime.events.filter((event) => event.category === "agent" && event.scope_category === "end").length, 1) }) it("records session lifecycle events, message metadata, errors, and deleted flushes", async () => { const dir = await makeTempDir() - const server = createServerPlugin({ loadRuntime: async () => createFakeRuntime() }) + const { runtime, server } = createHarness() const hooks = await server( { directory: dir }, { enabled: true, - atofPath: "./.nemoflow/opencode.atof.jsonl", - atifPath: "./.nemoflow/opencode.atif.json", logPath: "./.nemoflow/opencode-plugin.log", + plugins: pluginConfig(), }, ) @@ -286,13 +322,9 @@ describe("NeMo Flow OpenCode plugin", () => { }, }) - 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")) + const names = eventNames(runtime.events) + const message = runtime.events.find((event) => event.name === "opencode.chat.message") + const serialized = JSON.stringify(runtime.events) assert.ok(names.includes("opencode.session.created")) assert.ok(names.includes("opencode.session.updated")) @@ -303,14 +335,13 @@ describe("NeMo Flow OpenCode plugin", () => { 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")) + assert.ok(names.includes("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 }) + const { runtime, server } = createHarness() + const hooks = await server({ directory: dir }, { enabled: true, plugins: pluginConfig() }) await assert.doesNotReject(async () => { await hooks["chat.message"]?.({ agent: "build" }, { message: { id: "msg_missing" } }) @@ -318,29 +349,100 @@ describe("NeMo Flow OpenCode plugin", () => { 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"))) + assert.equal(runtime.events.length, 0) }) 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 }) + const { pluginHost, server } = createHarness() + const hooks = await server({ directory: dir }, { enabled: false, plugins: pluginConfig() }) assert.deepEqual(hooks, {}) - await assert.rejects(fs.stat(path.join(dir, ".nemoflow", "opencode.atof.jsonl"))) + assert.equal(pluginHost.validateCalls.length, 0) + assert.equal(pluginHost.initializeCalls.length, 0) }) 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 { server } = createHarness({ loadError: new Error("missing native binding") }) + const hooks = await server({ directory: dir }, { enabled: true, plugins: pluginConfig() }) + + assert.deepEqual(hooks, {}) + const log = await fs.readFile(path.join(dir, ".nemoflow", "opencode-plugin.log"), "utf8") + assert.match(log, /pass-through/) + }) + + it("logs and disables hooks when removed exporter options are used", async () => { + const dir = await makeTempDir() + const { pluginHost, server } = createHarness() + const hooks = await server( + { directory: dir }, + { + enabled: true, + atofPath: "./.nemoflow/opencode.atof.jsonl", + logPath: "./.nemoflow/opencode-plugin.log", }, + ) + + assert.deepEqual(hooks, {}) + assert.equal(pluginHost.validateCalls.length, 0) + const log = await fs.readFile(path.join(dir, ".nemoflow", "opencode-plugin.log"), "utf8") + assert.match(log, /config invalid/) + assert.match(log, /atofPath was removed/) + }) + + it("logs and disables hooks when generic plugin validation fails", async () => { + const dir = await makeTempDir() + const { pluginHost, server } = createHarness({ + validateDiagnostics: [ + { + level: "error", + code: "plugin.unknown_component", + component: "missing", + message: "unknown component", + }, + ], }) - const hooks = await server({ directory: dir }, { enabled: true }) + const hooks = await server({ directory: dir }, { enabled: true, plugins: pluginConfig() }) assert.deepEqual(hooks, {}) + assert.equal(pluginHost.validateCalls.length, 1) + assert.equal(pluginHost.initializeCalls.length, 0) const log = await fs.readFile(path.join(dir, ".nemoflow", "opencode-plugin.log"), "utf8") - assert.match(log, /pass-through/) + assert.match(log, /plugin.unknown_component/) + assert.match(log, /plugin host config validation failed/) + }) + + it("flushes open sessions and clears the plugin host during cleanup", async () => { + const dir = await makeTempDir() + const { runtime, pluginHost, server, cleanup } = createHarness() + const hooks = await server({ directory: dir }, { enabled: true, plugins: pluginConfig() }) + + await hooks["chat.message"]?.( + { + sessionID: "ses_3", + agent: "build", + model: { providerID: "test-provider", modelID: "test-model" }, + }, + { + message: { id: "msg_3", role: "user", agent: "build" }, + parts: [], + }, + ) + await hooks["tool.execute.before"]?.( + { tool: "write", sessionID: "ses_3", callID: "call_3" }, + { args: { path: "left-open.txt" } }, + ) + + await cleanup() + + const names = eventNames(runtime.events) + const pendingToolEnd = runtime.events.find( + (event) => event.category === "tool" && event.scope_category === "end" && event.metadata.callID === "call_3", + ) + assert.equal(pluginHost.clearCalls, 1) + assert.ok(names.includes("opencode.session.flush")) + assert.equal(pendingToolEnd.data.status, "unknown") + assert.equal(runtime.events.filter((event) => event.category === "agent" && event.scope_category === "end").length, 1) }) }) diff --git a/package-lock.json b/package-lock.json index 75de429a..64daa6fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "workspaces": [ "crates/node", "crates/wasm", - "integrations/openclaw" + "integrations/openclaw", + "integrations/opencode" ], "devDependencies": { "typescript": "^5.9.3" @@ -848,6 +849,61 @@ "openclaw": ">=2026.5.6" } }, + "integrations/opencode": { + "name": "nemo-flow-opencode", + "version": "0.2.0", + "license": "Apache-2.0", + "dependencies": { + "nemo-flow-node": ">0.1.0 <1.0.0" + }, + "devDependencies": { + "@opencode-ai/plugin": "^1.14.40" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@opencode-ai/plugin": ">=1.14.40" + } + }, + "integrations/opencode/node_modules/@opencode-ai/plugin": { + "version": "1.14.51", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.51.tgz", + "integrity": "sha512-2110+2U+SdD90lNYHOmMx5Nkp2d9hitiZmSjsWm4H5BlzmiObJRzNV2HdYL1wPnScgglAYiwgrne08a3Z+XJNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.14.51", + "effect": "4.0.0-beta.65", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.2.10", + "@opentui/keymap": ">=0.2.10", + "@opentui/solid": ">=0.2.10" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/keymap": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "integrations/opencode/node_modules/zod": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", + "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@agentclientprotocol/sdk": { "version": "0.21.0", "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.21.0.tgz", @@ -2531,6 +2587,90 @@ "node": ">=14.0.0" } }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "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/@nodable/entities": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", @@ -2544,6 +2684,16 @@ "license": "MIT", "peer": true }, + "node_modules/@opencode-ai/sdk": { + "version": "1.14.51", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.51.tgz", + "integrity": "sha512-qaoVzYvUDm2rXohrC2GjMlMaThHUKxxDZlBMYt2U6gyPaZ/kLLUCUPOff3CSzLoX9Mx7cBaqpV9F/Evm5PIDsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -3288,6 +3438,13 @@ "node": ">=18.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@telegraf/types": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/@telegraf/types/-/types-7.1.0.tgz", @@ -4245,6 +4402,17 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/diff": { "version": "8.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", @@ -4366,6 +4534,39 @@ "license": "MIT", "peer": true }, + "node_modules/effect": { + "version": "4.0.0-beta.65", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.65.tgz", + "integrity": "sha512-QYKvQPAj3CmtsvWkHQww15wX4KG2gNsszDWEcOO5sZCMknp66u6Si/Opmt3wwWCwsyvRmDAdIg+JIz5qzbbFIw==", + "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/effect/node_modules/uuid": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -4679,6 +4880,29 @@ "@types/yauzl": "^2.9.1" } }, + "node_modules/fast-check": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", + "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==", + "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/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4844,6 +5068,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "dev": true, + "license": "MIT" + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -5511,6 +5742,16 @@ "license": "ISC", "peer": true }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -5710,6 +5951,13 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -6015,6 +6263,46 @@ "license": "MIT", "peer": true }, + "node_modules/msgpackr": { + "version": "1.11.12", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz", + "integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "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", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", + "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", + "dev": true, + "license": "MIT" + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -6045,6 +6333,10 @@ "resolved": "integrations/openclaw", "link": true }, + "node_modules/nemo-flow-opencode": { + "resolved": "integrations/opencode", + "link": true + }, "node_modules/nemo-flow-wasm": { "resolved": "crates/wasm", "link": true @@ -6229,6 +6521,22 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "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/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -6864,6 +7172,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -7678,6 +8003,16 @@ "url": "https://github.com/sponsors/vincentkoc" } }, + "node_modules/toml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", + "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", diff --git a/package.json b/package.json index c9b61631..89f501d8 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "workspaces": [ "crates/node", "crates/wasm", - "integrations/openclaw" + "integrations/openclaw", + "integrations/opencode" ], "devDependencies": { "typescript": "^5.9.3" From 9310111361fc167fb503c3e3b4afd44c81d6fa5e Mon Sep 17 00:00:00 2001 From: Binfeng Xu Date: Fri, 15 May 2026 02:55:17 -0400 Subject: [PATCH 09/11] doc, llm & tool span wrapper, debug --- docs/integrate-frameworks/opencode.md | 319 ++++---------- integrations/opencode/README.md | 52 ++- integrations/opencode/server.js | 472 +++++++++++++++++---- integrations/opencode/test/server.test.mjs | 135 +++++- 4 files changed, 607 insertions(+), 371 deletions(-) diff --git a/docs/integrate-frameworks/opencode.md b/docs/integrate-frameworks/opencode.md index 6a36539a..19790aa7 100644 --- a/docs/integrate-frameworks/opencode.md +++ b/docs/integrate-frameworks/opencode.md @@ -5,93 +5,49 @@ SPDX-License-Identifier: Apache-2.0 # 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. +NeMo Flow integrates with OpenCode through the `nemo-flow-opencode` 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. -The OpenCode plugin maps those hook payloads into NeMo Flow scopes and events. -The generic NeMo Flow `observability` plugin config controls ATOF, ATIF, -OpenTelemetry, and OpenInference export. +LLM calls, successful tool calls, and session errors. The plugin maps OpenCode +hook payloads into NeMo Flow session, LLM, and tool spans. The generic NeMo +Flow `observability` component controls ATOF, ATIF, OpenTelemetry, and +OpenInference export. -## What You Build +## Requirements -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 diagram shows the split between OpenCode hook capture and generic NeMo Flow -export configuration. - -```{mermaid} -flowchart LR - User[Developer] - OpenCode[Stock OpenCode] - Plugin[NeMo Flow
OpenCode plugin] - Runtime[NeMo Flow
Node.js binding] - Host[Generic NeMo Flow
plugin host] - ATOF[(ATOF JSONL)] - ATIF[(ATIF JSON)] - OTLP[(OTLP traces)] - - User -->|uses normally| OpenCode - OpenCode -->|public plugin hooks| Plugin - Plugin -->|scopes, marks,
tool lifecycle| Runtime - Plugin -->|plugins config| Host - Host -->|observability component| Runtime - Runtime -->|raw events| ATOF - Runtime -->|agent trajectories| ATIF - Runtime -->|optional traces| OTLP - - class User blue-lightest; - class OpenCode green-lightest; - class Plugin purple-lightest; - class Host purple-light; - class Runtime green-light; - class ATOF yellow-lightest; - class ATIF yellow-lightest; - class OTLP yellow-lightest; -``` - -The plugin is passive. It records observability output but does not rewrite -prompts, tool arguments, model requests, or OpenCode execution behavior. +- OpenCode with server plugin support. +- Node.js 20 or newer. +- A NeMo Flow Node.js binding package compatible with `nemo-flow-opencode`. +- Provider credentials configured in OpenCode. ## 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: +Install the plugin with the OpenCode CLI: ```bash -export NEMO_FLOW_REPO=/absolute/path/to/NeMo-Flow -cd "$NEMO_FLOW_REPO/crates/node" -npm install -npm run build +opencode plugin nemo-flow-opencode ``` -For local development, install or use stock OpenCode and point `opencode.json` -at the plugin directory: +You can also install the package in the Node.js environment where OpenCode +loads plugins: ```bash -npm install -g opencode-ai@latest -opencode --version +npm install nemo-flow-opencode ``` -When the plugin package is published, use `nemo-flow-opencode` in the OpenCode -config instead of the local file URL. +OpenCode uses the package name `nemo-flow-opencode` in the `plugin` array. -## Configure OpenCode +## Enable and Configure the Plugin -Create or update `opencode.json` in the OpenCode project directory. The -top-level OpenCode plugin fields use JavaScript-style names such as `logPath`. -The nested `plugins` object is the generic NeMo Flow plugin config, so its -field names are `snake_case` in every language. +Create or update `opencode.json` in the OpenCode project directory: ```json { "plugin": [ [ - "file:///absolute/path/to/NeMo-Flow/integrations/opencode", + "nemo-flow-opencode", { "enabled": true, "logPath": "./.nemoflow/opencode-plugin.log", @@ -114,69 +70,18 @@ field names are `snake_case` in every language. "agent_name": "opencode", "output_directory": "./.nemoflow", "filename_template": "opencode-{session_id}.atif.json" - } - } - } - ] - } - } - ] - ] -} -``` - -The OpenCode plugin resolves `logPath` and observability `output_directory` -values relative to the OpenCode project directory. Other generic plugin fields -keep NeMo Flow's standard behavior. If `nemo-flow-node` is missing, plugin -validation fails, or plugin initialization fails, the plugin logs one warning -and returns no hooks, so OpenCode continues in pass-through mode. - -The ATIF filename placeholder `{session_id}` is the NeMo Flow top-level agent -scope UUID. The OpenCode session ID is still recorded in event metadata. - -For the complete `observability` component schema, see -[Configure the Observability Plugin](../export-observability-data/observability-plugin.md). - -## Run A Local Smoke - -Use this smoke when you want to check 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 < opencode.json <>OC: Start OpenCode in a project - OC->>Plug: server(input, options) - Plug->>Host: Validate and initialize plugins config - 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: Close session scope - NF->>Files: Write ATIF JSON ``` -## Pass-Through Checks +This example enables filesystem ATOF and ATIF export and leaves OTLP exporters +disabled until you point them at a collector or Phoenix endpoint. Remove +exporter sections you do not use, or set their `enabled` fields to `false`. + +- `plugin[][0]` is the OpenCode plugin package name. Use + `nemo-flow-opencode`. +- `enabled` disables or enables the NeMo Flow OpenCode wrapper without removing + the plugin entry. +- `logPath` writes JSONL diagnostics for plugin initialization and + pass-through behavior. +- `plugins` is the generic NeMo Flow plugin configuration document. Use this + object to configure built-in components such as `observability`. +- `plugins.components[].config.atof` writes raw ATOF JSONL lifecycle events. +- `plugins.components[].config.atif` writes ATIF trajectory JSON files. +- `plugins.components[].config.opentelemetry` sends generic OTLP spans to an + OpenTelemetry collector when `enabled` is `true`. +- `plugins.components[].config.openinference` sends OpenInference OTLP spans to + Phoenix or another OpenInference-compatible collector when `enabled` is + `true`. + +## Configuration Key Names + +OpenCode wrapper fields use JavaScript-style names, such as `logPath`. + +The top-level `plugins` object inside the wrapper is the generic NeMo Flow +plugin config. Fields inside this object use NeMo Flow generic plugin names, so +they are `snake_case` in every binding. + +Missing observability sections are disabled. Plugin-host validation or +initialization failures leave OpenCode in pass-through mode and write a warning +to `logPath`. -The plugin should not change OpenCode behavior when observability is disabled, -when the NeMo Flow runtime is unavailable, or when the generic plugin config is -invalid. - -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 -z "$(find ./.nemoflow -name 'opencode-*.atif.json' -print -quit)" -mv opencode.enabled.json opencode.json -``` - -Force runtime initialization failure: +The ATIF filename placeholder `{session_id}` is the NeMo Flow top-level agent +scope UUID. The OpenCode session ID is recorded in event metadata. -```bash -rm -f ./.nemoflow/opencode.* +See [Configure the Observability Plugin](../export-observability-data/observability-plugin.md) +for the complete `observability` component schema and exporter-specific fields. -NEMO_FLOW_OPENCODE_FORCE_INIT_FAILURE=1 opencode run \ - --title "nemo-flow init failure smoke" \ - "Reply with exactly: init failure smoke." +The plugin is passive. It records observability output but does not rewrite +prompts, tool arguments, model requests, or OpenCode execution behavior. -grep -i "pass-through" ./.nemoflow/opencode-plugin.log -test ! -s ./.nemoflow/opencode.atof.jsonl -``` +OpenCode streaming message events are used internally to reconstruct concise +LLM responses. They are not exported as individual ATIF steps. -## Limits +## Known Limitations The current OpenCode plugin API is enough for passive observability. It is not enough for NeMo Flow request intercepts, execution intercepts, conditional diff --git a/integrations/opencode/README.md b/integrations/opencode/README.md index d817dd13..f1204eb1 100644 --- a/integrations/opencode/README.md +++ b/integrations/opencode/README.md @@ -5,23 +5,38 @@ SPDX-License-Identifier: Apache-2.0 # NeMo Flow OpenCode Plugin -This package is a standalone OpenCode server plugin for NeMo Flow +`nemo-flow-opencode` is a standalone OpenCode server plugin for NeMo Flow observability. It uses OpenCode's public plugin API and does not require -patching OpenCode. +patching OpenCode. It maps OpenCode activity into NeMo Flow session, LLM, and +tool spans for the generic observability plugin. -For the illustrated setup guide, see `docs/integrate-frameworks/opencode.md` -in the NeMo Flow source checkout. +For the full guide, see `docs/integrate-frameworks/opencode.md` in the NeMo +Flow documentation. -## Configuration +## Install -Use the plugin from an OpenCode config file. From a NeMo Flow source checkout, -use a file URL: +Install the plugin with the OpenCode CLI: + +```bash +opencode plugin nemo-flow-opencode +``` + +You can also install the package in the Node.js environment where OpenCode +loads plugins: + +```bash +npm install nemo-flow-opencode +``` + +## Configure + +Use the package name in `opencode.json`: ```json { "plugin": [ [ - "file:///absolute/path/to/NeMo-Flow/integrations/opencode", + "nemo-flow-opencode", { "enabled": true, "logPath": "./.nemoflow/opencode-plugin.log", @@ -54,23 +69,13 @@ use a file URL: } ``` -When this package is published, replace the file URL with the package name -`nemo-flow-opencode`. - -The package loads `nemo-flow-node` and `nemo-flow-node/plugin` 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 plugin APIs through the -`@opencode-ai/plugin` peer dependency. It uses public OpenCode server plugin -hooks available in `@opencode-ai/plugin` `1.14.40` and newer. +Fields inside `plugins` are NeMo Flow generic plugin configuration, so they use +`snake_case`. The OpenCode wrapper fields use JavaScript-style names, such as +`logPath`. ## Output -Output is controlled by the generic NeMo Flow `plugins` config. Configure the -built-in `observability` component to write: +Configure the built-in `observability` component to write: - ATOF JSONL events with `plugins.components[].config.atof`. - ATIF trajectory files with `plugins.components[].config.atif`. @@ -78,6 +83,9 @@ built-in `observability` component to write: `plugins.components[].config.opentelemetry` or `openinference`. - JSONL plugin diagnostics with the OpenCode wrapper `logPath` field. +OpenCode streaming message events are used internally to reconstruct concise +LLM responses. They are not exported as individual ATIF steps. + ## Current Limitations This plugin uses only existing OpenCode hooks. OpenCode does not yet expose an diff --git a/integrations/opencode/server.js b/integrations/opencode/server.js index 1440892d..c8a4d15b 100644 --- a/integrations/opencode/server.js +++ b/integrations/opencode/server.js @@ -10,7 +10,7 @@ const DEFAULT_PLUGIN_HOST_CONFIG = Object.freeze({ components: Object.freeze([]), }) const RECENT_FLUSH_TTL_MS = 2000 -const RELEVANT_EVENTS = new Set([ +const OBSERVED_EVENTS = new Set([ "session.created", "session.updated", "session.deleted", @@ -23,6 +23,7 @@ const RELEVANT_EVENTS = new Set([ "message.part.delta", "message.part.removed", ]) +const INTERNAL_LLM_AGENTS = new Set(["title"]) /** * Create the plugin logger. @@ -259,6 +260,46 @@ function agentName(input, fallback = "opencode") { return fallback } +/** + * Keep provider config out of telemetry while preserving useful identity. + */ +function compactProvider(provider) { + if (!provider || typeof provider !== "object") return undefined + return toJsonSafe({ + id: provider.id, + source: provider.source, + env: provider.env, + }) +} + +/** + * Keep model config out of telemetry while preserving useful identity. + */ +function compactModel(model) { + if (!model || typeof model !== "object") return undefined + return toJsonSafe({ + id: model.id ?? model.modelID, + modelID: model.modelID, + providerID: model.providerID ?? model.provider?.id, + name: model.name, + family: model.family, + }) +} + +/** + * Keep only LLM parameter fields that describe the current call. + */ +function compactParams(params) { + if (!params || typeof params !== "object") return {} + return toJsonSafe({ + temperature: params.temperature, + topP: params.topP, + topK: params.topK, + maxOutputTokens: params.maxOutputTokens, + options: params.options, + }) +} + /** * Read the OpenCode session ID from a bus event payload. */ @@ -267,6 +308,13 @@ function eventSessionID(event) { return props?.sessionID ?? props?.info?.id } +/** + * Return true for OpenCode helper calls that should not appear in the agent trajectory. + */ +function shouldSkipLlm(input) { + return INTERNAL_LLM_AGENTS.has(input?.agent) +} + /** * Build metadata attached to the NeMo Flow session scope. */ @@ -332,6 +380,7 @@ async function initializePluginHost(pluginHost, config, logger) { throw new Error("NeMo Flow plugin host config validation failed") } + await ensureObservabilityOutputDirectories(config) const activationReport = await pluginHost.initialize(config) await logDiagnostics(logger, activationReport.diagnostics) if (hasErrorDiagnostics(activationReport)) { @@ -339,6 +388,25 @@ async function initializePluginHost(pluginHost, config, logger) { } } +/** + * Create filesystem output directories before exporter registration opens files. + */ +async function ensureObservabilityOutputDirectories(config) { + const components = Array.isArray(config?.components) ? config.components : [] + for (const component of components) { + if (component?.kind !== "observability" || component.enabled === false) continue + const observabilityConfig = component.config + for (const sectionName of ["atof", "atif"]) { + const section = observabilityConfig?.[sectionName] + if (section?.enabled !== true) continue + const outputDirectory = section.output_directory + if (typeof outputDirectory === "string" && outputDirectory.trim() !== "") { + await fs.mkdir(outputDirectory, { recursive: true }) + } + } + } +} + /** * Summarize the active generic plugin config for diagnostics. */ @@ -420,6 +488,11 @@ function createNemoFlowAdapter(lib, pluginHost, logger) { stack: typeof lib.createScopeStack === "function" ? lib.createScopeStack() : undefined, scope: undefined, pendingTools: new Map(), + toolCalls: new Map(), + pendingLlm: undefined, + messages: new Map(), + lastUserMessage: undefined, + sequence: 0, } session.scope = withStack(session, () => @@ -434,11 +507,6 @@ function createNemoFlowAdapter(lib, pluginHost, logger) { ), ) sessions.set(sessionID, session) - emitMark(session, "opencode.session.observed", { - sessionID, - agent: session.agent, - model: session.model, - }) return session } @@ -447,17 +515,237 @@ function createNemoFlowAdapter(lib, pluginHost, logger) { */ function emitMark(session, name, data, metadata = {}) { if (!session?.scope) return - lib.event( - name, - session.scope, - toJsonSafe(data), - { - source: "opencode", + withStack(session, () => + lib.event( + name, + session.scope, + toJsonSafe(data), + { + source: "opencode", + sessionID: session.id, + ...toJsonSafe(metadata), + }, + null, + ), + ) + } + + /** + * Record message and part bus events for later LLM response reconstruction. + */ + function recordMessageEvent(session, event) { + const props = event?.properties ?? {} + session.sequence += 1 + + if (event.type === "message.updated" && props.info) { + const info = toJsonSafe(props.info) + const messageID = info?.id + if (!messageID) return + const message = session.messages.get(messageID) ?? { id: messageID, parts: new Map(), firstSequence: session.sequence } + message.info = info + message.role = info.role + message.agent = info.agent + message.model = info.model + message.tokens = info.tokens + message.cost = info.cost + session.messages.set(messageID, message) + return + } + + if (event.type === "message.part.updated" && props.part) { + const part = toJsonSafe(props.part) + const messageID = part?.messageID + const partID = part?.id + if (!messageID || !partID) return + const message = session.messages.get(messageID) ?? { id: messageID, parts: new Map(), firstSequence: session.sequence } + const existing = message.parts.get(partID) ?? { id: partID, firstSequence: session.sequence } + message.parts.set(partID, { + ...existing, + ...part, + firstSequence: existing.firstSequence, + updatedSequence: session.sequence, + }) + session.messages.set(messageID, message) + return + } + + if (event.type === "message.part.delta") { + const partID = props.partID + const messageID = props.messageID + if (!partID || !messageID || props.field !== "text") return + const message = session.messages.get(messageID) ?? { id: messageID, parts: new Map(), firstSequence: session.sequence } + const existing = message.parts.get(partID) ?? { + id: partID, + messageID, sessionID: session.id, - ...toJsonSafe(metadata), + type: "text", + firstSequence: session.sequence, + } + message.parts.set(partID, { + ...existing, + text: `${existing.text ?? ""}${props.delta ?? ""}`, + updatedSequence: session.sequence, + }) + session.messages.set(messageID, message) + return + } + + if (event.type === "message.part.removed") { + const partID = props.partID ?? props.part?.id + const messageID = props.messageID ?? props.part?.messageID + if (partID && messageID) session.messages.get(messageID)?.parts.delete(partID) + } + } + + /** + * Build a compact NeMo Flow LLM request from OpenCode chat params. + */ + function buildLlmRequest(session, input, output) { + const userMessage = session.lastUserMessage + const promptText = textFromParts(userMessage?.parts) + return toJsonSafe({ + headers: {}, + content: { + source: "opencode.chat.params", + agent: input.agent, + messageID: input.message?.id ?? userMessage?.message?.id ?? userMessage?.input?.messageID, + provider: compactProvider(input.provider), + model: compactModel(input.model), + params: compactParams(output), + messages: promptText + ? [ + { + role: userMessage?.message?.role ?? input.message?.role ?? "user", + content: promptText, + }, + ] + : [], + }, + }) + } + + /** + * Build a compact NeMo Flow LLM response from OpenCode message bus state. + */ + function buildLlmResponse(session, pending, reason) { + const messages = assistantMessagesSince(session, pending.sequenceStart) + const content = messages.flatMap((message) => messageParts(message, "text")).map((part) => part.text).join("\n") + const toolCalls = messages.flatMap((message) => toolCallsFromMessage(session, message, pending.sequenceStart)) + const usage = usageFromMessages(messages) + + return toJsonSafe({ + role: "assistant", + content: content || undefined, + tool_calls: toolCalls.length > 0 ? toolCalls : undefined, + usage, + opencode: { + close_reason: reason, + agent: pending.agent, + messageID: pending.messageID, }, - null, + }) + } + + /** + * Start a semantic LLM span for a user-visible OpenCode model call. + */ + function startLlm(session, input, output) { + if (typeof lib.llmCall !== "function" || shouldSkipLlm(input)) return + closeActiveLlm(session, "next_llm_request") + + const model = modelName(input.model) ?? session.model + const metadata = toJsonSafe({ + source: "opencode.chat.params", + sessionID: session.id, + agent: input.agent, + messageID: input.message?.id, + model, + }) + const handle = withStack(session, () => + lib.llmCall( + input.provider?.id ?? input.model?.providerID ?? "opencode", + buildLlmRequest(session, input, output), + session.scope, + null, + null, + metadata, + model ?? null, + null, + ), ) + session.pendingLlm = { + handle, + agent: input.agent, + messageID: input.message?.id, + model, + sequenceStart: session.sequence + 1, + metadata, + } + } + + /** + * Finish the active LLM span before a tool starts, another LLM starts, or the session closes. + */ + function closeActiveLlm(session, reason) { + const pending = session?.pendingLlm + if (!pending || typeof lib.llmCallEnd !== "function") return + const response = buildLlmResponse(session, pending, reason) + withStack(session, () => lib.llmCallEnd(pending.handle, response, null, pending.metadata, null)) + session.pendingLlm = undefined + } + + /** + * Extract text from OpenCode message parts. + */ + function textFromParts(parts) { + if (!Array.isArray(parts)) return "" + return parts + .filter((part) => part?.type === "text" && typeof part.text === "string") + .map((part) => part.text) + .join("\n") + } + + function assistantMessagesSince(session, sequenceStart) { + return [...session.messages.values()] + .filter((message) => message.role === "assistant") + .filter((message) => [...message.parts.values()].some((part) => part.firstSequence >= sequenceStart)) + } + + function messageParts(message, type) { + return [...message.parts.values()].filter((part) => part.type === type && typeof part.text === "string") + } + + function toolCallsFromMessage(session, message, sequenceStart) { + return [...message.parts.values()] + .filter((part) => part.type === "tool" && part.firstSequence >= sequenceStart) + .filter((part) => part.callID || part.tool) + .map((part) => { + const observed = part.callID ? session.toolCalls.get(part.callID) : undefined + return { + id: part.callID ?? "", + type: "function", + function: { + name: observed?.tool ?? part.tool ?? "", + arguments: JSON.stringify(observed?.args ?? part.state?.input ?? {}), + }, + } + }) + } + + function usageFromMessages(messages) { + const message = [...messages].reverse().find((item) => item.tokens || item.cost !== undefined) + if (!message) return undefined + const input = Number.isFinite(message.tokens?.input) ? message.tokens.input : undefined + const output = Number.isFinite(message.tokens?.output) ? message.tokens.output : undefined + const cacheRead = Number.isFinite(message.tokens?.cache?.read) ? message.tokens.cache.read : 0 + const cacheWrite = Number.isFinite(message.tokens?.cache?.write) ? message.tokens.cache.write : 0 + return toJsonSafe({ + input_tokens: input, + output_tokens: output, + cached_tokens: cacheRead + cacheWrite, + reasoning_tokens: message.tokens?.reasoning, + cost_usd: message.cost, + }) } /** @@ -468,14 +756,16 @@ function createNemoFlowAdapter(lib, pluginHost, logger) { if (!session) return recentFlushes.set(sessionID, Date.now()) pruneRecentFlushes() - emitMark(session, "opencode.session.flush", { sessionID, reason }) + closeActiveLlm(session, 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 }, + withStack(session, () => + 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) @@ -507,28 +797,33 @@ function createNemoFlowAdapter(lib, pluginHost, logger) { }, /** - * Record relevant OpenCode bus events as NeMo Flow marks. + * Observe OpenCode bus events for response reconstruction and session closure. */ async recordEvent(event) { - if (closed || !RELEVANT_EVENTS.has(event?.type)) return + if (closed || !OBSERVED_EVENTS.has(event?.type)) return const sessionID = eventSessionID(event) if (!sessionID) return if (shouldFlushEvent(event) && wasRecentlyFlushed(sessionID)) return const props = event.properties ?? {} const session = ensureSession(sessionID, { - agent: agentName(props.info, undefined), + agent: typeof props.info?.agent === "string" ? props.info.agent : 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 (event.type.startsWith("message.")) { + recordMessageEvent(session, event) + } else if (event.type === "session.error") { + emitMark( + session, + "opencode.session.error", + { + id: event.id, + error: props.error, + }, + eventMetadata(session, { eventType: event.type }), + ) + } + if (shouldFlushEvent(event)) { flushSession(sessionID, event.type) } @@ -540,20 +835,15 @@ function createNemoFlowAdapter(lib, pluginHost, logger) { async recordChatMessage(input, output) { if (closed) return const session = ensureSession(input.sessionID, { - agent: agentName(input), + agent: agentName(input, agentName(output)), 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 }), - ) + session.lastUserMessage = toJsonSafe({ + input, + message: output?.message, + parts: output?.parts, + }) }, /** @@ -566,20 +856,7 @@ function createNemoFlowAdapter(lib, pluginHost, logger) { 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 }), - ) + startLlm(session, input, output) }, /** @@ -590,15 +867,21 @@ function createNemoFlowAdapter(lib, pluginHost, logger) { 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, + if (input.callID) { + session.toolCalls.set(input.callID, { tool: input.tool, args }) + } + closeActiveLlm(session, "tool_start") + const handle = withStack(session, () => + 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 }) }, @@ -613,28 +896,35 @@ function createNemoFlowAdapter(lib, pluginHost, logger) { 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, + if (input.callID) { + session.toolCalls.set(input.callID, { tool: input.tool, args }) + } + const handle = withStack(session, () => + 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, + withStack(session, () => + 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) }, @@ -643,6 +933,7 @@ function createNemoFlowAdapter(lib, pluginHost, logger) { * Flush open sessions and unregister exporters during plugin shutdown. */ async close() { + if (closed) return closed = true for (const sessionID of [...sessions.keys()]) { flushSession(sessionID, "plugin-close") @@ -677,13 +968,20 @@ async function loadDefaultModules() { * Register process cleanup for OpenCode runs without an explicit close hook. */ function registerBeforeExitCleanup(close, logger) { + let started = false const listener = () => { + if (started) return + started = true void close().catch((error) => { void logger.warnOnce("before-exit-cleanup", "failed to clean up NeMo Flow OpenCode plugin", error) }) } process.on("beforeExit", listener) - return () => process.removeListener("beforeExit", listener) + process.on("exit", listener) + return () => { + process.removeListener("beforeExit", listener) + process.removeListener("exit", listener) + } } /** diff --git a/integrations/opencode/test/server.test.mjs b/integrations/opencode/test/server.test.mjs index 188f5904..dd85ef55 100644 --- a/integrations/opencode/test/server.test.mjs +++ b/integrations/opencode/test/server.test.mjs @@ -37,6 +37,7 @@ function createFakeRuntime() { kind: "scope", category: "agent", scope_category: "start", + stack: activeStack.id, uuid: handle.uuid, name, data, @@ -50,6 +51,7 @@ function createFakeRuntime() { kind: "scope", category: "agent", scope_category: "end", + stack: activeStack.id, uuid: handle.uuid, name: handle.name, data: output, @@ -58,6 +60,7 @@ function createFakeRuntime() { event(name, handle, data, metadata) { events.push({ kind: "mark", + stack: activeStack.id, uuid: `mark-${++counter}`, parent_uuid: handle?.uuid, name, @@ -76,6 +79,7 @@ function createFakeRuntime() { kind: "scope", category: "tool", scope_category: "start", + stack: activeStack.id, uuid: tool.uuid, name, parent_uuid: handle?.uuid, @@ -89,16 +93,51 @@ function createFakeRuntime() { kind: "scope", category: "tool", scope_category: "end", + stack: activeStack.id, uuid: handle.uuid, name: handle.name, data: result ?? data, metadata, }) }, + llmCall(name, request, handle, _attributes, _data, metadata, modelName) { + const llm = { + uuid: `llm-${++counter}`, + name, + parentUuid: handle?.uuid, + modelName, + } + events.push({ + kind: "scope", + category: "llm", + scope_category: "start", + stack: activeStack.id, + uuid: llm.uuid, + name, + parent_uuid: handle?.uuid, + data: request, + metadata, + modelName, + }) + return llm + }, + llmCallEnd(handle, response, data, metadata) { + events.push({ + kind: "scope", + category: "llm", + scope_category: "end", + stack: activeStack.id, + uuid: handle.uuid, + name: handle.name, + data: response ?? data, + metadata, + modelName: handle.modelName, + }) + }, } } -function createFakePluginHost({ validateDiagnostics = [], initializeDiagnostics = [] } = {}) { +function createFakePluginHost({ validateDiagnostics = [], initializeDiagnostics = [], onInitialize } = {}) { return { validateCalls: [], initializeCalls: [], @@ -108,6 +147,7 @@ function createFakePluginHost({ validateDiagnostics = [], initializeDiagnostics return { diagnostics: validateDiagnostics } }, async initialize(config) { + await onInitialize?.(config) this.initializeCalls.push(config) return { diagnostics: initializeDiagnostics } }, @@ -172,10 +212,29 @@ async function makeTempDir() { return fs.mkdtemp(path.join(os.tmpdir(), "nemo-flow-opencode-")) } +async function statIsDirectory(targetPath) { + try { + return (await fs.stat(targetPath)).isDirectory() + } catch { + return false + } +} + function eventNames(events) { return events.map((event) => event.name).filter(Boolean) } +function assertSessionStackUsed(runtime) { + const sessionStart = runtime.events.find( + (event) => event.category === "agent" && event.scope_category === "start", + ) + assert.ok(sessionStart?.stack?.startsWith("stack-")) + for (const event of runtime.events) { + assert.equal(event.stack, sessionStart.stack, `${event.name ?? event.category} should use session scope stack`) + } + assert.equal(runtime.currentScopeStack().id, "current") +} + describe("NeMo Flow OpenCode plugin", () => { it("initializes generic plugin config and records OpenCode hooks until idle", async () => { const dir = await makeTempDir() @@ -192,6 +251,7 @@ describe("NeMo Flow OpenCode plugin", () => { const expectedOutputDirectory = path.join(dir, ".nemoflow") assert.equal(pluginHost.validateCalls.length, 1) assert.equal(pluginHost.initializeCalls.length, 1) + assert.ok(await statIsDirectory(expectedOutputDirectory)) assert.equal(pluginHost.validateCalls[0].components[0].config.atof.output_directory, expectedOutputDirectory) assert.equal(pluginHost.validateCalls[0].components[0].config.atif.output_directory, expectedOutputDirectory) assert.deepEqual(pluginHost.initializeCalls[0], pluginHost.validateCalls[0]) @@ -219,6 +279,34 @@ describe("NeMo Flow OpenCode plugin", () => { }, { temperature: 0, topP: 1, topK: 0, options: {} }, ) + await hooks.event?.({ + event: { + id: "evt_assistant", + type: "message.updated", + properties: { + sessionID: "ses_1", + info: { id: "msg_assistant_1", role: "assistant", agent: "build", sessionID: "ses_1" }, + }, + }, + }) + await hooks.event?.({ + event: { + id: "evt_tool_part", + type: "message.part.updated", + properties: { + sessionID: "ses_1", + part: { + id: "part_tool_1", + messageID: "msg_assistant_1", + sessionID: "ses_1", + type: "tool", + tool: "write", + callID: "call_1", + state: { input: {}, status: "pending" }, + }, + }, + }, + }) await hooks["tool.execute.before"]?.( { tool: "write", sessionID: "ses_1", callID: "call_1" }, { args: { path: "phase1-demo.txt" } }, @@ -243,15 +331,32 @@ describe("NeMo Flow OpenCode plugin", () => { }) const names = eventNames(runtime.events) - assert.ok(names.includes("opencode.chat.message")) - assert.ok(names.includes("opencode.llm.request")) - assert.equal(names.filter((name) => name === "opencode.session.flush").length, 1) + assert.equal(names.includes("opencode.chat.message"), false) + assert.equal(names.includes("opencode.llm.request"), false) + assert.equal(names.filter((name) => name === "opencode.session.flush").length, 0) + assert.equal(runtime.events.filter((event) => event.category === "llm" && event.scope_category === "start").length, 1) + assert.equal(runtime.events.filter((event) => event.category === "llm" && event.scope_category === "end").length, 1) + const llmEnd = runtime.events.find((event) => event.category === "llm" && event.scope_category === "end") + assert.deepEqual(JSON.parse(llmEnd.data.tool_calls[0].function.arguments), { path: "phase1-demo.txt" }) assert.equal(runtime.events.filter((event) => event.category === "tool" && event.scope_category === "start").length, 1) assert.equal(runtime.events.filter((event) => event.category === "tool" && event.scope_category === "end").length, 1) assert.equal(runtime.events.filter((event) => event.category === "agent" && event.scope_category === "end").length, 1) + assertSessionStackUsed(runtime) + }) + + it("creates observability output directories before plugin host initialization", async () => { + const dir = await makeTempDir() + const expectedOutputDirectory = path.join(dir, ".nemoflow") + const { server } = createHarness({ + async onInitialize() { + assert.ok(await statIsDirectory(expectedOutputDirectory)) + }, + }) + + await server({ directory: dir }, { enabled: true, plugins: pluginConfig() }) }) - it("records session lifecycle events, message metadata, errors, and deleted flushes", async () => { + it("keeps normal bus events internal while recording session errors", async () => { const dir = await makeTempDir() const { runtime, server } = createHarness() const hooks = await server( @@ -323,19 +428,18 @@ describe("NeMo Flow OpenCode plugin", () => { }) const names = eventNames(runtime.events) - const message = runtime.events.find((event) => event.name === "opencode.chat.message") + const error = runtime.events.find((event) => event.name === "opencode.session.error") const serialized = JSON.stringify(runtime.events) - assert.ok(names.includes("opencode.session.created")) - assert.ok(names.includes("opencode.session.updated")) + assert.equal(names.includes("opencode.session.created"), false) + assert.equal(names.includes("opencode.session.updated"), false) 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.equal(names.includes("opencode.session.deleted"), false) + assert.equal(error.metadata.sessionID, "ses_2") + assert.equal(error.metadata.agent, "review") + assert.equal(error.metadata.model, "anthropic/claude-test") assert.match(serialized, /"apiKey":"\[Redacted\]"/) - assert.match(serialized, /"outputTokens":3/) - assert.ok(names.includes("opencode.session.flush")) + assert.equal(names.includes("opencode.session.flush"), false) }) it("ignores hooks without an OpenCode session identifier", async () => { @@ -441,8 +545,9 @@ describe("NeMo Flow OpenCode plugin", () => { (event) => event.category === "tool" && event.scope_category === "end" && event.metadata.callID === "call_3", ) assert.equal(pluginHost.clearCalls, 1) - assert.ok(names.includes("opencode.session.flush")) + assert.equal(names.includes("opencode.session.flush"), false) assert.equal(pendingToolEnd.data.status, "unknown") assert.equal(runtime.events.filter((event) => event.category === "agent" && event.scope_category === "end").length, 1) + assertSessionStackUsed(runtime) }) }) From b3067c6fc42ac1352e1d756596f5d2d1f8ffd9d9 Mon Sep 17 00:00:00 2001 From: Binfeng Xu Date: Fri, 15 May 2026 11:18:36 -0400 Subject: [PATCH 10/11] addressing comments --- integrations/opencode/server.js | 237 +++++++++++++-------- integrations/opencode/test/server.test.mjs | 176 +++++++++++++++ 2 files changed, 326 insertions(+), 87 deletions(-) diff --git a/integrations/opencode/server.js b/integrations/opencode/server.js index c8a4d15b..ed1c41d1 100644 --- a/integrations/opencode/server.js +++ b/integrations/opencode/server.js @@ -5,10 +5,6 @@ import fs from "node:fs/promises" import path from "node:path" const PLUGIN_ID = "nemo-flow-opencode" -const DEFAULT_PLUGIN_HOST_CONFIG = Object.freeze({ - version: 1, - components: Object.freeze([]), -}) const RECENT_FLUSH_TTL_MS = 2000 const OBSERVED_EVENTS = new Set([ "session.created", @@ -49,9 +45,10 @@ function createLogger(logPath) { 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 ?? "") + const loggedExtra = record.extra ?? "" + if (level === "error") console.error(text, loggedExtra) + else if (level === "warn") console.warn(text, loggedExtra) + else console.info(text, loggedExtra) } return { @@ -112,7 +109,7 @@ function normalizeOptions(input, options = {}) { */ function normalizePluginHostConfig(baseDir, value) { if (value === undefined) { - return clonePluginHostConfig(DEFAULT_PLUGIN_HOST_CONFIG) + return defaultPluginHostConfig(baseDir) } const raw = asRecord(value, "plugins", false) @@ -123,25 +120,63 @@ function normalizePluginHostConfig(baseDir, value) { throw new Error("plugins.components must be an array") } + const normalizedComponents = components.map((component, index) => + normalizePluginComponent(baseDir, component, `plugins.components[${index}]`), + ) + return { ...raw, version, - components: components.map((component, index) => - normalizePluginComponent(baseDir, component, `plugins.components[${index}]`), - ), + components: withDefaultObservabilityComponent(baseDir, normalizedComponents), } } /** - * Clone the mutable generic plugin config before giving it to the runtime. + * Build the default generic plugin config used by the OpenCode wrapper. */ -function clonePluginHostConfig(config) { +function defaultPluginHostConfig(baseDir) { return { - ...config, - components: [...config.components], + version: 1, + components: [defaultObservabilityComponent(baseDir)], } } +/** + * Add a default observability sink unless the caller configured one explicitly. + */ +function withDefaultObservabilityComponent(baseDir, components) { + if (components.some((component) => component?.kind === "observability")) return components + return [...components, defaultObservabilityComponent(baseDir)] +} + +/** + * Create the OpenCode filesystem observability defaults. + */ +function defaultObservabilityComponent(baseDir) { + return normalizePluginComponent( + baseDir, + { + kind: "observability", + enabled: true, + config: { + version: 1, + atof: { + enabled: true, + output_directory: "./.nemoflow", + filename: "opencode.atof.jsonl", + }, + atif: { + enabled: true, + agent_name: "opencode", + output_directory: "./.nemoflow", + filename_template: "opencode-{session_id}.atif.json", + }, + }, + }, + "plugins.components[0]", + ) +} + /** * Normalize path-bearing sections on the built-in observability component. */ @@ -468,6 +503,18 @@ function createNemoFlowAdapter(lib, pluginHost, logger) { } } + /** + * Keep NeMo Flow observability failures from changing OpenCode hook behavior. + */ + function runtimeCall(warnKey, message, callback) { + try { + return callback() + } catch (error) { + void logger.warnOnce(warnKey, message, error).catch(() => {}) + return undefined + } + } + /** * Create or update the NeMo Flow session state for an OpenCode session. */ @@ -495,17 +542,21 @@ function createNemoFlowAdapter(lib, pluginHost, logger) { sequence: 0, } - session.scope = withStack(session, () => - lib.pushScope( - "opencode.session", - lib.ScopeType?.Agent ?? 0, - null, - null, - { sessionID }, - inputSessionMetadata(sessionID, session), - { sessionID, source: "opencode" }, + const scope = runtimeCall(`scope-push:${sessionID}`, "failed to start OpenCode session scope", () => + withStack(session, () => + lib.pushScope( + "opencode.session", + lib.ScopeType?.Agent ?? 0, + null, + null, + { sessionID }, + inputSessionMetadata(sessionID, session), + { sessionID, source: "opencode" }, + ), ), ) + if (!scope) return undefined + session.scope = scope sessions.set(sessionID, session) return session } @@ -515,17 +566,19 @@ function createNemoFlowAdapter(lib, pluginHost, logger) { */ function emitMark(session, name, data, metadata = {}) { if (!session?.scope) return - withStack(session, () => - lib.event( - name, - session.scope, - toJsonSafe(data), - { - source: "opencode", - sessionID: session.id, - ...toJsonSafe(metadata), - }, - null, + runtimeCall(`mark:${name}`, "failed to record OpenCode mark event", () => + withStack(session, () => + lib.event( + name, + session.scope, + toJsonSafe(data), + { + source: "opencode", + sessionID: session.id, + ...toJsonSafe(metadata), + }, + null, + ), ), ) } @@ -661,18 +714,21 @@ function createNemoFlowAdapter(lib, pluginHost, logger) { messageID: input.message?.id, model, }) - const handle = withStack(session, () => - lib.llmCall( - input.provider?.id ?? input.model?.providerID ?? "opencode", - buildLlmRequest(session, input, output), - session.scope, - null, - null, - metadata, - model ?? null, - null, + const handle = runtimeCall("llm-start", "failed to start OpenCode LLM span", () => + withStack(session, () => + lib.llmCall( + input.provider?.id ?? input.model?.providerID ?? "opencode", + buildLlmRequest(session, input, output), + session.scope, + null, + null, + metadata, + model ?? null, + null, + ), ), ) + if (!handle) return session.pendingLlm = { handle, agent: input.agent, @@ -690,7 +746,9 @@ function createNemoFlowAdapter(lib, pluginHost, logger) { const pending = session?.pendingLlm if (!pending || typeof lib.llmCallEnd !== "function") return const response = buildLlmResponse(session, pending, reason) - withStack(session, () => lib.llmCallEnd(pending.handle, response, null, pending.metadata, null)) + runtimeCall("llm-end", "failed to close OpenCode LLM span", () => + withStack(session, () => lib.llmCallEnd(pending.handle, response, null, pending.metadata, null)), + ) session.pendingLlm = undefined } @@ -758,7 +816,7 @@ function createNemoFlowAdapter(lib, pluginHost, logger) { pruneRecentFlushes() closeActiveLlm(session, reason) for (const [key, tool] of session.pendingTools) { - try { + runtimeCall(`tool-close:${key}`, "failed to close pending OpenCode tool span", () => withStack(session, () => lib.toolCallEnd( tool.handle, @@ -766,19 +824,15 @@ function createNemoFlowAdapter(lib, pluginHost, logger) { 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) - } + runtimeCall(`scope-pop:${sessionID}`, "failed to close OpenCode session scope", () => + withStack(session, () => lib.popScope(session.scope, { sessionID, reason }, null)), + ) } sessions.delete(sessionID) @@ -809,6 +863,7 @@ function createNemoFlowAdapter(lib, pluginHost, logger) { agent: typeof props.info?.agent === "string" ? props.info.agent : undefined, model: modelName(props.info?.model), }) + if (!session) return if (event.type.startsWith("message.")) { recordMessageEvent(session, event) @@ -871,18 +926,21 @@ function createNemoFlowAdapter(lib, pluginHost, logger) { session.toolCalls.set(input.callID, { tool: input.tool, args }) } closeActiveLlm(session, "tool_start") - const handle = withStack(session, () => - 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, + const handle = runtimeCall(`tool-start:${input.callID ?? input.tool}`, "failed to start OpenCode tool span", () => + withStack(session, () => + 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, + ), ), ) + if (!handle) return session.pendingTools.set(input.callID, { handle, callID: input.callID, tool: input.tool, args }) }, @@ -899,31 +957,36 @@ function createNemoFlowAdapter(lib, pluginHost, logger) { if (input.callID) { session.toolCalls.set(input.callID, { tool: input.tool, args }) } - const handle = withStack(session, () => - 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, + const handle = runtimeCall(`tool-start:${input.callID ?? input.tool}`, "failed to start OpenCode tool span", () => + withStack(session, () => + 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, + ), ), ) + if (!handle) return pending = { handle, callID: input.callID, tool: input.tool, args } } - withStack(session, () => - lib.toolCallEnd( - pending.handle, - toJsonSafe({ - title: output?.title, - output: output?.output, - metadata: output?.metadata, - }), - null, - { source: "opencode", sessionID: input.sessionID, callID: input.callID }, - null, + runtimeCall(`tool-end:${input.callID ?? input.tool}`, "failed to close OpenCode tool span", () => + withStack(session, () => + 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) diff --git a/integrations/opencode/test/server.test.mjs b/integrations/opencode/test/server.test.mjs index dd85ef55..3f408432 100644 --- a/integrations/opencode/test/server.test.mjs +++ b/integrations/opencode/test/server.test.mjs @@ -212,6 +212,21 @@ async function makeTempDir() { return fs.mkdtemp(path.join(os.tmpdir(), "nemo-flow-opencode-")) } +async function waitForLogMatch(logPath, pattern) { + let log = "" + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + log = await fs.readFile(logPath, "utf8") + if (pattern.test(log)) return log + } catch (error) { + if (error.code !== "ENOENT") throw error + } + await new Promise((resolve) => setTimeout(resolve, 10)) + } + assert.match(log, pattern) + return log +} + async function statIsDirectory(targetPath) { try { return (await fs.stat(targetPath)).isDirectory() @@ -356,6 +371,24 @@ describe("NeMo Flow OpenCode plugin", () => { await server({ directory: dir }, { enabled: true, plugins: pluginConfig() }) }) + it("registers default observability output when plugins are omitted", async () => { + const dir = await makeTempDir() + const { pluginHost, server } = createHarness() + const hooks = await server({ directory: dir }) + + assert.equal(typeof hooks.event, "function") + assert.equal(pluginHost.validateCalls.length, 1) + assert.equal(pluginHost.initializeCalls.length, 1) + const component = pluginHost.validateCalls[0].components[0] + assert.equal(component.kind, "observability") + assert.equal(component.config.atof.enabled, true) + assert.equal(component.config.atof.output_directory, path.join(dir, ".nemoflow")) + assert.equal(component.config.atof.filename, "opencode.atof.jsonl") + assert.equal(component.config.atif.enabled, true) + assert.equal(component.config.atif.output_directory, path.join(dir, ".nemoflow")) + assert.equal(component.config.atif.filename_template, "opencode-{session_id}.atif.json") + }) + it("keeps normal bus events internal while recording session errors", async () => { const dir = await makeTempDir() const { runtime, server } = createHarness() @@ -517,6 +550,149 @@ describe("NeMo Flow OpenCode plugin", () => { assert.match(log, /plugin host config validation failed/) }) + it("redacts console diagnostics when file logging is disabled", async () => { + const dir = await makeTempDir() + const warnings = [] + const originalWarn = console.warn + console.warn = (...args) => { + warnings.push(args) + } + + try { + const { server } = createHarness({ + validateDiagnostics: [ + { + level: "error", + code: "plugin.invalid", + message: "invalid config", + apiKey: "secret-value", + }, + ], + }) + const hooks = await server({ directory: dir }, { enabled: true, logPath: "", plugins: pluginConfig() }) + + assert.deepEqual(hooks, {}) + } finally { + console.warn = originalWarn + } + + assert.ok(warnings.length > 0) + assert.equal(warnings[0][1].apiKey, "[Redacted]") + assert.equal(JSON.stringify(warnings).includes("secret-value"), false) + }) + + it("keeps hooks non-fatal when NeMo Flow runtime calls throw", async () => { + async function assertRuntimeFailure(mutator, runHook, pattern) { + const dir = await makeTempDir() + const runtime = createFakeRuntime() + mutator(runtime) + const { server } = createHarness({ runtime }) + const hooks = await server({ directory: dir }, { enabled: true, plugins: pluginConfig() }) + + await assert.doesNotReject(async () => { + await runHook(hooks) + }) + await waitForLogMatch(path.join(dir, ".nemoflow", "opencode-plugin.log"), pattern) + } + + await assertRuntimeFailure( + (runtime) => { + runtime.pushScope = () => { + throw new Error("scope failed") + } + }, + (hooks) => + hooks["chat.message"]?.( + { sessionID: "ses_scope", agent: "build" }, + { message: { id: "msg_scope", role: "user" }, parts: [] }, + ), + /failed to start OpenCode session scope/, + ) + + await assertRuntimeFailure( + (runtime) => { + runtime.event = () => { + throw new Error("event failed") + } + }, + (hooks) => + hooks.event?.({ + event: { + id: "evt_error", + type: "session.error", + properties: { sessionID: "ses_event", error: { message: "provider failed" } }, + }, + }), + /failed to record OpenCode mark event/, + ) + + await assertRuntimeFailure( + (runtime) => { + runtime.llmCall = () => { + throw new Error("llm failed") + } + }, + (hooks) => + hooks["chat.params"]?.( + { sessionID: "ses_llm", agent: "build", model: { providerID: "test-provider", id: "test-model" } }, + {}, + ), + /failed to start OpenCode LLM span/, + ) + + await assertRuntimeFailure( + (runtime) => { + runtime.llmCallEnd = () => { + throw new Error("llm end failed") + } + }, + async (hooks) => { + await hooks["chat.params"]?.( + { sessionID: "ses_llm_end", agent: "build", model: { providerID: "test-provider", id: "test-model" } }, + {}, + ) + await hooks["tool.execute.before"]?.( + { tool: "write", sessionID: "ses_llm_end", callID: "call_llm_end" }, + { args: { path: "x" } }, + ) + }, + /failed to close OpenCode LLM span/, + ) + + await assertRuntimeFailure( + (runtime) => { + runtime.toolCall = () => { + throw new Error("tool failed") + } + }, + (hooks) => + hooks["tool.execute.before"]?.( + { tool: "write", sessionID: "ses_tool", callID: "call_tool" }, + { args: { path: "x" } }, + ), + /failed to start OpenCode tool span/, + ) + + await assertRuntimeFailure( + (runtime) => { + runtime.toolCallEnd = () => { + throw new Error("tool end failed") + } + }, + async (hooks) => { + await hooks["tool.execute.before"]?.( + { tool: "write", sessionID: "ses_tool_end", callID: "call_tool_end" }, + { args: { path: "x" } }, + ) + await hooks["tool.execute.after"]?.( + { tool: "write", sessionID: "ses_tool_end", callID: "call_tool_end" }, + { output: "done" }, + ) + }, + /failed to close OpenCode tool span/, + ) + }) + it("flushes open sessions and clears the plugin host during cleanup", async () => { const dir = await makeTempDir() const { runtime, pluginHost, server, cleanup } = createHarness() From af12512a5a94e1485f42f11722eced35d08cbc88 Mon Sep 17 00:00:00 2001 From: Binfeng Xu Date: Fri, 15 May 2026 11:23:56 -0400 Subject: [PATCH 11/11] nit --- integrations/opencode/test/server.test.mjs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/integrations/opencode/test/server.test.mjs b/integrations/opencode/test/server.test.mjs index 3f408432..87669017 100644 --- a/integrations/opencode/test/server.test.mjs +++ b/integrations/opencode/test/server.test.mjs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import assert from "node:assert/strict" +import { randomUUID } from "node:crypto" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" @@ -28,7 +29,7 @@ function createFakeRuntime() { }, pushScope(name, scopeType, _parent, attributes, data, metadata, input) { const handle = { - uuid: `scope-${++counter}`, + uuid: randomUUID(), name, scopeType, attributes, @@ -61,7 +62,7 @@ function createFakeRuntime() { events.push({ kind: "mark", stack: activeStack.id, - uuid: `mark-${++counter}`, + uuid: randomUUID(), parent_uuid: handle?.uuid, name, data, @@ -70,7 +71,7 @@ function createFakeRuntime() { }, toolCall(name, args, handle, _attributes, data, metadata, toolCallID) { const tool = { - uuid: `tool-${++counter}`, + uuid: randomUUID(), name, parentUuid: handle?.uuid, toolCallID, @@ -102,7 +103,7 @@ function createFakeRuntime() { }, llmCall(name, request, handle, _attributes, _data, metadata, modelName) { const llm = { - uuid: `llm-${++counter}`, + uuid: randomUUID(), name, parentUuid: handle?.uuid, modelName,