From 8f61a67957b3732e65a8faf89d22d95a949e5385 Mon Sep 17 00:00:00 2001 From: "daibo@machinepulse.ai" Date: Tue, 28 Apr 2026 12:40:29 +0800 Subject: [PATCH 1/9] feat(openclaw-plugin): bridge W2A sensors to OpenClaw via runEmbeddedAgent Native OpenClaw plugin that loads enabled sensors from ~/.world2agent/sensors.json in-process, queues each emitted signal per sensor session, and dispatches it through api.runtime.agent.runEmbeddedAgent so a fresh agent turn handles every signal. Verified end-to-end against a local OpenClaw 2026.4.15 gateway with @world2agent/sensor-hackernews + openrouter/moonshotai/kimi-k2.6. Hard contracts enforced at register(): - agents.defaults.contextInjection must equal "continuation-skip"; missing setting causes plugin start to throw with the exact field path to fix. Same check fires up front in `openclaw world2agent sensor add` so the install path fails before mutating manifest state. - api.runtime.agent.runEmbeddedAgent must be present; surfaces as an M0 spike message rather than a silent no-op. - register() stays synchronous; OpenClaw's plugin loader drops registers that return a Promise. Async startup work runs as fire-and-forget. Other notable bits: - Resolves provider/model from agents.defaults.model.primary because runEmbeddedAgent itself ignores the operator default and falls back to openai/gpt-5.4 when nothing is passed. - Pins sessionId per sensor (`w2a-`, sanitised against SAFE_SESSION_ID_RE) so continuation turns reuse the same transcript; sessionKey carries the colon-namespaced lane for run serialisation. - HttpDispatcher (POST /w2a/ingest, HMAC + X-Request-ID dedup) and the IsolatedRunner skeleton are wired so out-of-process sensors can come online without re-architecting the plugin. - Skill-routing default is the dedicated W2A agent's agents.list[].skills allowlist; prompt-prefix `Use skill: ` is the M1 fallback when no allowlist is configured. Tests: 9 vitest cases covering manifest read/write, dispatcher serialisation + prompt-prefix fallback, HttpDispatcher HMAC/dedup, contextInjection startup throw, and synchronous register. Co-Authored-By: Claude Opus 4.7 (1M context) --- openclaw-plugin/.gitignore | 4 + openclaw-plugin/README.md | 54 + openclaw-plugin/openclaw.plugin.json | 66 + openclaw-plugin/package-lock.json | 1322 +++++++++++++++++ openclaw-plugin/package.json | 69 + .../skills/world2agent-manage/SKILL.md | 79 + .../skills/world2agent-manage/scripts/add.sh | 5 + .../skills/world2agent-manage/scripts/list.sh | 5 + .../world2agent-manage/scripts/reload.sh | 4 + .../world2agent-manage/scripts/remove.sh | 5 + openclaw-plugin/src/cli.ts | 220 +++ openclaw-plugin/src/config.ts | 133 ++ openclaw-plugin/src/dispatch.ts | 263 ++++ openclaw-plugin/src/index.ts | 7 + openclaw-plugin/src/install.ts | 223 +++ openclaw-plugin/src/isolated.ts | 240 +++ openclaw-plugin/src/manifest.ts | 176 +++ .../src/openclaw/plugin-sdk/plugin-entry.ts | 6 + .../src/openclaw/plugin-sdk/types.ts | 135 ++ openclaw-plugin/src/paths.ts | 93 ++ openclaw-plugin/src/plugin.ts | 117 ++ openclaw-plugin/src/prompt.ts | 44 + openclaw-plugin/src/runner/bin.ts | 129 ++ openclaw-plugin/src/runner/config-stream.ts | 4 + openclaw-plugin/src/runner/http-transport.ts | 64 + openclaw-plugin/src/runtime.ts | 160 ++ openclaw-plugin/src/supervisor/shared.ts | 117 ++ openclaw-plugin/src/types.ts | 104 ++ .../test/context-injection.test.ts | 52 + openclaw-plugin/test/dispatch.test.ts | 146 ++ openclaw-plugin/test/manifest.test.ts | 89 ++ .../test/supervisor-shared.test.ts | 84 ++ openclaw-plugin/tsconfig.json | 19 + openclaw-plugin/vitest.config.ts | 9 + 34 files changed, 4247 insertions(+) create mode 100644 openclaw-plugin/.gitignore create mode 100644 openclaw-plugin/README.md create mode 100644 openclaw-plugin/openclaw.plugin.json create mode 100644 openclaw-plugin/package-lock.json create mode 100644 openclaw-plugin/package.json create mode 100644 openclaw-plugin/skills/world2agent-manage/SKILL.md create mode 100755 openclaw-plugin/skills/world2agent-manage/scripts/add.sh create mode 100755 openclaw-plugin/skills/world2agent-manage/scripts/list.sh create mode 100755 openclaw-plugin/skills/world2agent-manage/scripts/reload.sh create mode 100755 openclaw-plugin/skills/world2agent-manage/scripts/remove.sh create mode 100644 openclaw-plugin/src/cli.ts create mode 100644 openclaw-plugin/src/config.ts create mode 100644 openclaw-plugin/src/dispatch.ts create mode 100644 openclaw-plugin/src/index.ts create mode 100644 openclaw-plugin/src/install.ts create mode 100644 openclaw-plugin/src/isolated.ts create mode 100644 openclaw-plugin/src/manifest.ts create mode 100644 openclaw-plugin/src/openclaw/plugin-sdk/plugin-entry.ts create mode 100644 openclaw-plugin/src/openclaw/plugin-sdk/types.ts create mode 100644 openclaw-plugin/src/paths.ts create mode 100644 openclaw-plugin/src/plugin.ts create mode 100644 openclaw-plugin/src/prompt.ts create mode 100644 openclaw-plugin/src/runner/bin.ts create mode 100644 openclaw-plugin/src/runner/config-stream.ts create mode 100644 openclaw-plugin/src/runner/http-transport.ts create mode 100644 openclaw-plugin/src/runtime.ts create mode 100644 openclaw-plugin/src/supervisor/shared.ts create mode 100644 openclaw-plugin/src/types.ts create mode 100644 openclaw-plugin/test/context-injection.test.ts create mode 100644 openclaw-plugin/test/dispatch.test.ts create mode 100644 openclaw-plugin/test/manifest.test.ts create mode 100644 openclaw-plugin/test/supervisor-shared.test.ts create mode 100644 openclaw-plugin/tsconfig.json create mode 100644 openclaw-plugin/vitest.config.ts diff --git a/openclaw-plugin/.gitignore b/openclaw-plugin/.gitignore new file mode 100644 index 0000000..c9c9210 --- /dev/null +++ b/openclaw-plugin/.gitignore @@ -0,0 +1,4 @@ +dist +node_modules +*.tsbuildinfo + diff --git a/openclaw-plugin/README.md b/openclaw-plugin/README.md new file mode 100644 index 0000000..266168d --- /dev/null +++ b/openclaw-plugin/README.md @@ -0,0 +1,54 @@ +# @world2agent/openclaw-plugin + +Native OpenClaw plugin for running World2Agent sensors and dispatching their signals into embedded OpenClaw agent turns. + +The default path is in-process: enabled sensors are imported directly inside the plugin process and each signal is sent to `api.runtime.agent.runEmbeddedAgent(...)`. `isolated: true` is opt-in and reuses the Hermes bridge runner/supervisor patterns for subprocess execution plus plugin-local HTTP ingest. + +## Install + +1. Set the required OpenClaw agent config first: + + ```yaml + agents: + defaults: + contextInjection: continuation-skip + ``` + +2. Install dependencies and build this package: + + ```bash + cd world2agent-plugins/openclaw-plugin + pnpm install + pnpm build + ``` + +3. Add the plugin package to your OpenClaw plugin search/install path and enable `@world2agent/openclaw-plugin`. + +4. Use the registered CLI: + + ```bash + openclaw world2agent sensor list + openclaw world2agent sensor add @world2agent/sensor-hackernews --config-file ./hackernews.json + ``` + +## Scope + +- Reads and writes the W2A sensor manifest at `~/.world2agent/sensors.json` by default. +- Runs sensors in-process unless a sensor entry sets `isolated: true`. +- Reuses the Hermes runner/supervisor patterns instead of inventing a second isolation protocol. +- Uses a stable per-sensor embedded-agent session id: `w2a:`. +- Requires plugin config `ingestUrl` only when `isolated: true` sensors are used. + +## ContextInjection Prerequisite + +This plugin refuses to start unless `agents.defaults.contextInjection` is exactly `"continuation-skip"`. + +That check also runs before `openclaw world2agent sensor add`. There is no warning mode, no fallback mode, and no override flag. The design requires a hard failure because OpenClaw's default `"always"` setting would re-inject bootstrap on every sensor signal and silently turn high-frequency sensors into a token sink. + +## Relation To `hermes-sensor-bridge` + +`hermes-sensor-bridge` solved the same World2Agent runtime problem for Hermes with webhook subscriptions plus supervised subprocesses. This package keeps the same manifest shape and reuses the runner/supervisor mechanics for `isolated: true`, but the primary OpenClaw path is simpler: native plugin registration plus `runEmbeddedAgent(...)`. + +## Known M0 Spike + +`api.runtime.agent.runEmbeddedAgent(...)` from a third-party external plugin remains a live-install verification point. This package guards it defensively and throws a clear error if the runtime helper is absent, but a real OpenClaw install still has to confirm the end-to-end external-plugin path. diff --git a/openclaw-plugin/openclaw.plugin.json b/openclaw-plugin/openclaw.plugin.json new file mode 100644 index 0000000..550e803 --- /dev/null +++ b/openclaw-plugin/openclaw.plugin.json @@ -0,0 +1,66 @@ +{ + "id": "world2agent", + "name": "World2Agent", + "description": "Run World2Agent sensors inside OpenClaw and dispatch signals into embedded agent turns.", + "skills": [ + "./skills/world2agent-manage" + ], + "commandAliases": [ + "world2agent" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "sensorsManifestPath": { + "type": "string", + "description": "Absolute or relative path to the World2Agent sensor manifest." + }, + "stateDir": { + "type": "string", + "description": "Directory for per-sensor FileSensorStore state." + }, + "sessionDir": { + "type": "string", + "description": "Directory for plugin-managed embedded-agent session files." + }, + "workspaceDir": { + "type": "string", + "description": "Workspace directory passed to runEmbeddedAgent when the OpenClaw runtime helper is unavailable." + }, + "ingestUrl": { + "type": "string", + "description": "Absolute URL for the plugin's /w2a/ingest route when isolated runners are enabled." + }, + "defaultAgentId": { + "type": "string", + "default": "world2agent", + "description": "Dedicated OpenClaw agent id whose skills allowlist should receive W2A skills." + }, + "provider": { + "type": "string", + "description": "Provider passed to runEmbeddedAgent (e.g. 'openai-codex' for OAuth, 'openai' for API key). When unset OpenClaw resolves from agent/global defaults." + }, + "model": { + "type": "string", + "description": "Model id passed to runEmbeddedAgent (e.g. 'gpt-5.4'). When unset OpenClaw resolves from agent/global defaults." + }, + "requestTimeoutMs": { + "type": "integer", + "minimum": 1, + "default": 120000, + "description": "Timeout passed to runEmbeddedAgent and isolated ingest POSTs." + }, + "ingestHmacSecretFile": { + "type": "string", + "description": "Path to the HMAC secret used by isolated runner ingest requests." + }, + "ingestDedupTtlMs": { + "type": "integer", + "minimum": 1000, + "default": 3600000, + "description": "How long X-Request-ID dedup entries stay in memory." + } + } + } +} diff --git a/openclaw-plugin/package-lock.json b/openclaw-plugin/package-lock.json new file mode 100644 index 0000000..a52177d --- /dev/null +++ b/openclaw-plugin/package-lock.json @@ -0,0 +1,1322 @@ +{ + "name": "@world2agent/openclaw-plugin", + "version": "0.0.0-dev", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@world2agent/openclaw-plugin", + "version": "0.0.0-dev", + "license": "Apache-2.0", + "dependencies": { + "@world2agent/sdk": "file:../../world2agent-typescript-sdk", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "typescript": "^5.9.3", + "vitest": "^4.1.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "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/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@world2agent/sdk": { + "version": "0.1.0-alpha.1", + "resolved": "file:../../world2agent-typescript-sdk", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "zod": "^3.25.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "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", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/openclaw-plugin/package.json b/openclaw-plugin/package.json new file mode 100644 index 0000000..aa785e8 --- /dev/null +++ b/openclaw-plugin/package.json @@ -0,0 +1,69 @@ +{ + "name": "@world2agent/openclaw-plugin", + "version": "0.0.0-dev", + "description": "World2Agent native plugin for OpenClaw", + "license": "Apache-2.0", + "author": "MachinePulse Pte. Ltd.", + "homepage": "https://github.com/machinepulse-ai/world2agent", + "repository": { + "type": "git", + "url": "git+https://github.com/machinepulse-ai/world2agent-plugins.git", + "directory": "openclaw-plugin" + }, + "bugs": { + "url": "https://github.com/machinepulse-ai/world2agent-plugins/issues" + }, + "keywords": [ + "world2agent", + "w2a", + "openclaw", + "plugin", + "sensor" + ], + "engines": { + "node": ">=20" + }, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc --build", + "clean": "rm -rf dist *.tsbuildinfo", + "test": "vitest run", + "test:watch": "vitest", + "prepublishOnly": "pnpm run clean && pnpm run build" + }, + "dependencies": { + "@world2agent/sdk": "file:../../world2agent-typescript-sdk", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "typescript": "^5.9.3", + "vitest": "^4.1.5" + }, + "files": [ + "dist", + "openclaw.plugin.json", + "skills", + "README.md" + ], + "openclaw": { + "extensions": [ + "./src/index.ts" + ], + "runtimeExtensions": [ + "./dist/index.js" + ] + }, + "publishConfig": { + "access": "public" + } +} + diff --git a/openclaw-plugin/skills/world2agent-manage/SKILL.md b/openclaw-plugin/skills/world2agent-manage/SKILL.md new file mode 100644 index 0000000..69ca8a7 --- /dev/null +++ b/openclaw-plugin/skills/world2agent-manage/SKILL.md @@ -0,0 +1,79 @@ +--- +name: world2agent-manage +description: Manage World2Agent sensors for OpenClaw. Use when the user asks to install, list, remove, or inspect W2A sensors, or wants an outside-world source such as Hacker News, GitHub, RSS, calendars, or market feeds. +user-invocable: false +--- + +# World2Agent Sensor Management + +You manage the user's World2Agent sensors on this OpenClaw machine. + +All mutations go through the `openclaw world2agent` CLI. The shell scripts in +`scripts/` are thin wrappers around those commands. + +## Prerequisite + +Before adding sensors, OpenClaw must be configured with: + +```yaml +agents: + defaults: + contextInjection: continuation-skip +``` + +If that field is not set exactly, `openclaw world2agent sensor add` will fail on +purpose. Do not try to work around it. + +## List sensors + +Run: + +```bash +bash "$W2A_PLUGIN_HOME/skills/world2agent-manage/scripts/list.sh" +``` + +## Install a sensor + +1. Confirm the npm package name with the user. +2. Inspect the sensor package's `SETUP.md` to determine the config fields it needs. +3. Write a temporary JSON file containing the sensor config object only. +4. Run: + +```bash +bash "$W2A_PLUGIN_HOME/skills/world2agent-manage/scripts/add.sh" --config-file +``` + +Optional flags: + +- `--sensor-id ` if the user wants a non-default instance id. +- `--isolated` if the sensor should run out-of-process. + +Never invent credentials or secrets. Ask the user when the config requires them. + +## Remove a sensor + +Run: + +```bash +bash "$W2A_PLUGIN_HOME/skills/world2agent-manage/scripts/remove.sh" +``` + +Pass `--purge` only if the user explicitly wants the generated OpenClaw skill +directory removed too. + +## Reload sensors + +Run: + +```bash +bash "$W2A_PLUGIN_HOME/skills/world2agent-manage/scripts/reload.sh" +``` + +## Output style + +After each action, summarize: + +- which sensor ids were affected +- whether the reload succeeded +- any warnings or errors returned by the CLI + diff --git a/openclaw-plugin/skills/world2agent-manage/scripts/add.sh b/openclaw-plugin/skills/world2agent-manage/scripts/add.sh new file mode 100755 index 0000000..7a38c22 --- /dev/null +++ b/openclaw-plugin/skills/world2agent-manage/scripts/add.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec openclaw world2agent sensor add "$@" + diff --git a/openclaw-plugin/skills/world2agent-manage/scripts/list.sh b/openclaw-plugin/skills/world2agent-manage/scripts/list.sh new file mode 100755 index 0000000..bb42a74 --- /dev/null +++ b/openclaw-plugin/skills/world2agent-manage/scripts/list.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec openclaw world2agent sensor list "$@" + diff --git a/openclaw-plugin/skills/world2agent-manage/scripts/reload.sh b/openclaw-plugin/skills/world2agent-manage/scripts/reload.sh new file mode 100755 index 0000000..6ed2b42 --- /dev/null +++ b/openclaw-plugin/skills/world2agent-manage/scripts/reload.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec openclaw world2agent reload "$@" diff --git a/openclaw-plugin/skills/world2agent-manage/scripts/remove.sh b/openclaw-plugin/skills/world2agent-manage/scripts/remove.sh new file mode 100755 index 0000000..a85fb9d --- /dev/null +++ b/openclaw-plugin/skills/world2agent-manage/scripts/remove.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec openclaw world2agent sensor remove "$@" + diff --git a/openclaw-plugin/src/cli.ts b/openclaw-plugin/src/cli.ts new file mode 100644 index 0000000..32705d6 --- /dev/null +++ b/openclaw-plugin/src/cli.ts @@ -0,0 +1,220 @@ +import { join } from "node:path"; +import { packageToSkillId } from "@world2agent/sdk"; +import { + assertContextInjectionCompatible, + loadEffectiveOpenClawConfig, + upsertDedicatedAgentSkillAllowlist, +} from "./config.js"; +import { ensurePackageInstalled, loadConfigFile, maybeUninstallPackage, runCommand, writeGeneratedSkill } from "./install.js"; +import { + defaultSensorId, + readManifest, + removePath, + removeSensorEntry, + upsertSensorEntry, + writeManifest, +} from "./manifest.js"; +import type { + OpenClawConfig, + OpenClawPluginApi, +} from "./openclaw/plugin-sdk/types.js"; +import type { RequiredWorld2AgentPluginConfig, SensorEntry, World2AgentPaths } from "./types.js"; + +export interface World2AgentCliServices { + api: OpenClawPluginApi; + paths: World2AgentPaths; + pluginConfig: RequiredWorld2AgentPluginConfig; +} + +export function registerWorld2AgentCli(services: World2AgentCliServices): void { + services.api.registerCli?.( + ({ program }) => { + const root = program.command("world2agent").description("Manage World2Agent sensors"); + const sensor = root.command("sensor").description("Manage sensor instances"); + + sensor + .command("list") + .description("List configured sensors") + .action(async () => { + printJson(await runListCommand(services)); + }); + + sensor + .command("add ") + .description("Install and configure a sensor") + .option("--sensor-id ", "Override the default sensor id") + .option("--config-file ", "Path to the sensor config JSON file") + .option("--isolated", "Run this sensor out-of-process") + .action(async (pkg: string, options: Record) => { + printJson(await runAddCommand(services, pkg, options)); + }); + + sensor + .command("remove ") + .description("Remove a configured sensor") + .option("--purge", "Remove the generated skill directory and best-effort uninstall the package") + .action(async (sensorId: string, options: Record) => { + printJson(await runRemoveCommand(services, sensorId, options)); + }); + + root + .command("reload") + .description("Ask the running gateway plugin instance to reload sensors") + .action(async () => { + printJson(await runReloadCommand()); + }); + }, + { + descriptors: [ + { + name: "world2agent", + description: "Manage World2Agent sensors", + hasSubcommands: true, + }, + ], + }, + ); +} + +async function runListCommand( + services: World2AgentCliServices, +): Promise { + const config = await loadEffectiveOpenClawConfig(services.api); + const manifest = await readManifest(services.paths); + return { + ok: true, + contextInjection: config.agents?.defaults?.contextInjection ?? null, + dedicated_agent_id: services.pluginConfig.defaultAgentId, + sensors: manifest.sensors, + }; +} + +async function runAddCommand( + services: World2AgentCliServices, + pkg: string, + options: Record, +): Promise { + const config = await loadEffectiveOpenClawConfig(services.api); + assertContextInjectionCompatible(config); + + const installed = await ensurePackageInstalled(pkg); + const sensorId = optionString(options, "sensorId") ?? defaultSensorId(pkg); + const configFile = optionString(options, "configFile"); + const isolated = optionBoolean(options, "isolated"); + const skillId = packageToSkillId(pkg); + const sensorConfig = await loadConfigFile(configFile, installed); + await writeGeneratedSkill(services.paths, pkg, installed); + + const manifest = await readManifest(services.paths); + const entry: SensorEntry = { + sensor_id: sensorId, + pkg, + skill_id: skillId, + enabled: true, + isolated, + config: sensorConfig, + }; + await writeManifest(services.paths, upsertSensorEntry(manifest, entry)); + + const allowlist = await maybePersistAllowlist( + services.api, + config, + services.pluginConfig.defaultAgentId, + skillId, + ); + const reload = await runReloadCommand(); + + return { + ok: true, + sensor_id: sensorId, + skill_id: skillId, + isolated, + allowlist, + reload, + }; +} + +async function runRemoveCommand( + services: World2AgentCliServices, + sensorId: string, + options: Record, +): Promise { + const manifest = await readManifest(services.paths); + const { manifest: nextManifest, removed } = removeSensorEntry(manifest, sensorId); + if (!removed) { + throw new Error(`Sensor not found: ${sensorId}`); + } + + await writeManifest(services.paths, nextManifest); + + const purge = optionBoolean(options, "purge"); + if (purge) { + await removePath(join(services.paths.openclawSkillsDir, removed.skill_id)); + const stillUsesPackage = nextManifest.sensors.some((entry) => entry.pkg === removed.pkg); + if (!stillUsesPackage) { + await maybeUninstallPackage(removed.pkg); + } + } + + return { + ok: true, + removed, + purge, + reload: await runReloadCommand(), + }; +} + +async function runReloadCommand(): Promise { + try { + const { stdout } = await runCommand("openclaw", [ + "gateway", + "call", + "world2agent.reload", + "--json", + ]); + + try { + return JSON.parse(stdout); + } catch { + return { + ok: true, + raw: stdout.trim(), + }; + } + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +async function maybePersistAllowlist( + api: OpenClawPluginApi, + config: OpenClawConfig, + agentId: string, + skillId: string, +): Promise { + const result = upsertDedicatedAgentSkillAllowlist(config, agentId, skillId); + if (result.changed && typeof api.runtime?.config?.writeConfigFile === "function") { + await api.runtime.config.writeConfigFile(result.nextConfig); + } + return { + changed: result.changed, + warning: result.warning, + }; +} + +function printJson(value: unknown): void { + process.stdout.write(JSON.stringify(value, null, 2) + "\n"); +} + +function optionString(options: Record, key: string): string | undefined { + const value = options[key]; + return typeof value === "string" && value.trim() !== "" ? value : undefined; +} + +function optionBoolean(options: Record, key: string): boolean { + return options[key] === true; +} + diff --git a/openclaw-plugin/src/config.ts b/openclaw-plugin/src/config.ts new file mode 100644 index 0000000..d22a810 --- /dev/null +++ b/openclaw-plugin/src/config.ts @@ -0,0 +1,133 @@ +import type { + OpenClawAgentConfig, + OpenClawConfig, + OpenClawPluginApi, + OpenClawPluginConfig, +} from "./openclaw/plugin-sdk/types.js"; +import type { RequiredWorld2AgentPluginConfig } from "./types.js"; + +export const REQUIRED_CONTEXT_INJECTION = "continuation-skip"; + +export async function loadEffectiveOpenClawConfig( + api: OpenClawPluginApi, +): Promise { + if (typeof api.runtime?.config?.loadConfig === "function") { + return api.runtime.config.loadConfig(); + } + return api.config ?? {}; +} + +export function assertContextInjectionCompatible(config: OpenClawConfig): void { + const got = config.agents?.defaults?.contextInjection; + if (got === REQUIRED_CONTEXT_INJECTION) return; + + throw new Error( + "OpenClaw config field `agents.defaults.contextInjection` must be set to " + + `"${REQUIRED_CONTEXT_INJECTION}" for @world2agent/openclaw-plugin. ` + + `Current value: ${JSON.stringify(got)}. Update that exact field and retry.`, + ); +} + +export function normalizePluginConfig( + value: unknown, +): RequiredWorld2AgentPluginConfig { + const raw = + value && typeof value === "object" && !Array.isArray(value) + ? (value as OpenClawPluginConfig) + : {}; + + return { + sensorsManifestPath: asOptionalString(raw.sensorsManifestPath), + stateDir: asOptionalString(raw.stateDir), + sessionDir: asOptionalString(raw.sessionDir), + workspaceDir: asOptionalString(raw.workspaceDir), + ingestUrl: asOptionalString(raw.ingestUrl), + defaultAgentId: asOptionalString(raw.defaultAgentId) ?? "world2agent", + provider: asOptionalString((raw as Record).provider), + model: asOptionalString((raw as Record).model), + requestTimeoutMs: asPositiveInteger(raw.requestTimeoutMs) ?? 120_000, + ingestHmacSecretFile: asOptionalString(raw.ingestHmacSecretFile), + ingestDedupTtlMs: asPositiveInteger(raw.ingestDedupTtlMs) ?? 3_600_000, + }; +} + +export function hasDedicatedAgentSkillsAllowlist( + config: OpenClawConfig, + agentId: string, +): boolean { + const agent = findDedicatedAgent(config, agentId); + return Array.isArray(agent?.skills); +} + +export function findDedicatedAgent( + config: OpenClawConfig, + agentId: string, +): OpenClawAgentConfig | undefined { + return config.agents?.list?.find( + (entry) => entry.id === agentId || entry.name === agentId, + ); +} + +export function upsertDedicatedAgentSkillAllowlist( + config: OpenClawConfig, + agentId: string, + skillId: string, +): { + changed: boolean; + nextConfig: OpenClawConfig; + warning: string | null; +} { + const currentAgent = findDedicatedAgent(config, agentId); + if (!currentAgent) { + return { + changed: false, + nextConfig: config, + warning: + `Dedicated agent ${JSON.stringify(agentId)} was not found in agents.list; ` + + "installed skill will rely on prompt-prefix fallback until that agent exists.", + }; + } + + const currentSkills = Array.isArray(currentAgent.skills) + ? [...currentAgent.skills] + : []; + if (currentSkills.includes(skillId)) { + return { + changed: false, + nextConfig: config, + warning: null, + }; + } + + currentSkills.push(skillId); + currentSkills.sort(); + + const nextConfig: OpenClawConfig = structuredClone(config); + const nextAgent = findDedicatedAgent(nextConfig, agentId); + if (!nextAgent) { + return { + changed: false, + nextConfig: config, + warning: + `Dedicated agent ${JSON.stringify(agentId)} disappeared while cloning config; ` + + "installed skill will rely on prompt-prefix fallback.", + }; + } + nextAgent.skills = currentSkills; + + return { + changed: true, + nextConfig, + warning: null, + }; +} + +function asOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() !== "" ? value : undefined; +} + +function asPositiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value > 0 + ? value + : undefined; +} diff --git a/openclaw-plugin/src/dispatch.ts b/openclaw-plugin/src/dispatch.ts new file mode 100644 index 0000000..7ebb112 --- /dev/null +++ b/openclaw-plugin/src/dispatch.ts @@ -0,0 +1,263 @@ +import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { join } from "node:path"; +import { hasDedicatedAgentSkillsAllowlist } from "./config.js"; +import { renderSignalPrompt } from "./prompt.js"; +import type { OpenClawConfig } from "./openclaw/plugin-sdk/types.js"; +import type { + Dispatcher, + DispatcherDispatchInput, + EmbeddedDispatcherOptions, + HttpDispatcherOptions, + HttpIngestEnvelope, +} from "./types.js"; + +const RUN_EMBEDDED_AGENT_ERROR = + "M0 spike unverified: api.runtime.agent.runEmbeddedAgent not found — verify against a live OpenClaw install"; + +export function assertEmbeddedAgentRuntime(options: EmbeddedDispatcherOptions): void { + if (typeof options.api.runtime?.agent?.runEmbeddedAgent !== "function") { + throw new Error(RUN_EMBEDDED_AGENT_ERROR); + } +} + +export class EmbeddedDispatcher implements Dispatcher { + private readonly options: EmbeddedDispatcherOptions; + private readonly queues = new Map>(); + + constructor(options: EmbeddedDispatcherOptions) { + assertEmbeddedAgentRuntime(options); + this.options = options; + } + + async dispatch(input: DispatcherDispatchInput): Promise { + // OpenClaw enforces SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i; + // colons aren't allowed in sessionId. sessionKey is the colon-namespaced lane. + const sessionId = input.sessionId ?? `w2a-${sanitizeSessionId(input.sensorId)}`; + const previous = this.queues.get(sessionId) ?? Promise.resolve(); + + const next = previous + .catch(() => undefined) + .then(() => this.dispatchNow(input, sessionId)); + + this.queues.set(sessionId, next); + try { + return await next; + } finally { + if (this.queues.get(sessionId) === next) { + this.queues.delete(sessionId); + } + } + } + + private async dispatchNow( + input: DispatcherDispatchInput, + sessionId: string, + ): Promise { + const prompt = renderSignalPrompt(input.signal, { + skillId: input.skillId, + useSkillPrefix: !hasDedicatedAgentSkillsAllowlist( + this.options.openclawConfigRef.current, + this.options.pluginConfig.defaultAgentId, + ), + }); + + const config = this.options.openclawConfigRef.current; + const runtimeAgent = this.options.api.runtime!.agent!; + const agentId = this.options.pluginConfig.defaultAgentId ?? "main"; + const sessionKey = `w2a:${input.sensorId}`; + const openclawHome = this.options.paths.openclawHome; + const workspaceDir = + this.options.pluginConfig.workspaceDir ?? + tryCall(() => runtimeAgent.resolveAgentWorkspaceDir?.(config, agentId)) ?? + join(openclawHome, "workspace"); + const agentDir = + tryCall(() => runtimeAgent.resolveAgentDir?.(config, agentId)) ?? + join(openclawHome, "agents", agentId); + const sessionFile = join(agentDir, "sessions", `${sessionId}.jsonl`); + const timeoutMs = + this.options.pluginConfig.requestTimeoutMs ?? + tryCall(() => runtimeAgent.resolveAgentTimeoutMs?.(config)) ?? + 120_000; + + // OpenClaw's runEmbeddedAgent silently defaults to "openai/gpt-5.4" when + // provider/model are absent — it does NOT read agents.defaults.model.primary. + // Resolve the effective default ourselves so signal-driven runs follow the + // operator's configured model. + const { provider, model } = resolveProviderAndModel( + config, + this.options.pluginConfig, + ); + + return runtimeAgent.runEmbeddedAgent!({ + sessionId, + sessionKey, + agentId, + runId: randomUUID(), + sessionFile, + workspaceDir, + agentDir, + config, + prompt, + timeoutMs, + ...(provider ? { provider } : {}), + ...(model ? { model } : {}), + } as Parameters>[0]); + } +} + +export class CliDispatcher implements Dispatcher { + async dispatch(_input: DispatcherDispatchInput): Promise { + // TODO: Keep this as an escape hatch only; do not make it load-bearing. + throw new Error("CliDispatcher is not implemented in M4 skeleton"); + } +} + +export class HttpDispatcher { + private readonly embeddedDispatcher: Dispatcher; + private readonly hmacSecret: string; + private readonly dedup = new RequestDeduper(); + private readonly dedupTtlMs: number; + + constructor(options: HttpDispatcherOptions) { + this.embeddedDispatcher = options.embeddedDispatcher; + this.hmacSecret = options.hmacSecret; + this.dedupTtlMs = options.dedupTtlMs; + } + + createRoute() { + return { + path: "/w2a/ingest", + auth: "plugin" as const, + handler: async (req: IncomingMessage, res: ServerResponse) => { + await this.handle(req, res); + }, + }; + } + + async handle(req: IncomingMessage, res: ServerResponse): Promise { + if (req.method !== "POST") { + writeJson(res, 405, { ok: false, error: "method not allowed" }); + return; + } + + const body = await readBody(req); + if (!this.verifyHmac(body, req.headers["x-webhook-signature"])) { + writeJson(res, 401, { ok: false, error: "invalid signature" }); + return; + } + + const requestId = req.headers["x-request-id"]; + if (typeof requestId !== "string" || requestId.trim() === "") { + writeJson(res, 400, { ok: false, error: "missing X-Request-ID" }); + return; + } + if (this.dedup.seen(requestId, this.dedupTtlMs)) { + writeJson(res, 202, { ok: true, deduped: true }); + return; + } + + let payload: HttpIngestEnvelope; + try { + payload = JSON.parse(body) as HttpIngestEnvelope; + } catch (error) { + writeJson(res, 400, { + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + + await this.embeddedDispatcher.dispatch({ + sensorId: payload.sensor_id, + skillId: payload.skill_id, + signal: payload.signal, + }); + + writeJson(res, 202, { ok: true }); + } + + private verifyHmac(body: string, signatureHeader: string | string[] | undefined): boolean { + if (typeof signatureHeader !== "string") return false; + const expected = Buffer.from( + createHmac("sha256", this.hmacSecret).update(body).digest("hex"), + "hex", + ); + const got = Buffer.from(signatureHeader, "hex"); + return expected.length === got.length && timingSafeEqual(expected, got); + } +} + +class RequestDeduper { + private readonly seenAt = new Map(); + + seen(id: string, ttlMs: number): boolean { + const now = Date.now(); + this.prune(now, ttlMs); + if (this.seenAt.has(id)) { + return true; + } + this.seenAt.set(id, now); + if (this.seenAt.size > 1_024) { + const oldest = this.seenAt.keys().next().value; + if (oldest) this.seenAt.delete(oldest); + } + return false; + } + + private prune(now: number, ttlMs: number): void { + for (const [id, seenAt] of this.seenAt) { + if (now - seenAt > ttlMs) { + this.seenAt.delete(id); + } + } + } +} + +function sanitizeSessionId(value: string): string { + // OpenClaw SAFE_SESSION_ID_RE: /^[a-z0-9][a-z0-9._-]{0,127}$/i. Map invalid + // chars to "-" (allowed) rather than "_" (also allowed but mixed-style). + return value.replace(/[^A-Za-z0-9._-]/g, "-"); +} + +function tryCall(fn: () => T): T | undefined { + try { + return fn(); + } catch { + return undefined; + } +} + +function resolveProviderAndModel( + config: OpenClawConfig, + pluginConfig: { provider?: string; model?: string }, +): { provider?: string; model?: string } { + if (pluginConfig.provider && pluginConfig.model) { + return { provider: pluginConfig.provider, model: pluginConfig.model }; + } + const primary = config.agents?.defaults?.model?.primary; + if (typeof primary === "string") { + const slash = primary.indexOf("/"); + if (slash > 0) { + return { + provider: pluginConfig.provider ?? primary.slice(0, slash), + model: pluginConfig.model ?? primary.slice(slash + 1), + }; + } + } + return { provider: pluginConfig.provider, model: pluginConfig.model }; +} + +async function readBody(req: IncomingMessage): Promise { + let raw = ""; + for await (const chunk of req) { + raw += chunk.toString(); + } + return raw; +} + +function writeJson(res: ServerResponse, status: number, body: unknown): void { + res.statusCode = status; + res.setHeader("content-type", "application/json; charset=utf-8"); + res.end(JSON.stringify(body, null, 2)); +} diff --git a/openclaw-plugin/src/index.ts b/openclaw-plugin/src/index.ts new file mode 100644 index 0000000..3ae4db4 --- /dev/null +++ b/openclaw-plugin/src/index.ts @@ -0,0 +1,7 @@ +import { createWorld2AgentPlugin } from "./plugin.js"; + +export { definePluginEntry } from "./openclaw/plugin-sdk/plugin-entry.js"; +export type * from "./openclaw/plugin-sdk/types.js"; +export { createWorld2AgentPlugin } from "./plugin.js"; + +export default createWorld2AgentPlugin(); diff --git a/openclaw-plugin/src/install.ts b/openclaw-plugin/src/install.ts new file mode 100644 index 0000000..2a007bf --- /dev/null +++ b/openclaw-plugin/src/install.ts @@ -0,0 +1,223 @@ +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { packageToSkillId } from "@world2agent/sdk"; +import { pathExists, removePath } from "./manifest.js"; +import type { World2AgentPaths } from "./types.js"; + +export interface InstalledPackageInfo { + packageJsonPath: string; + packageRoot: string; + packageJson: Record; +} + +export function pluginPackageRoot(): string { + return fileURLToPath(new URL("../", import.meta.url)); +} + +export async function resolveInstalledPackage( + pkg: string, +): Promise { + const require = createRequire(import.meta.url); + try { + const entryPath = require.resolve(pkg, { + paths: [pluginPackageRoot()], + }); + const packageJsonPath = await findNearestPackageJson(dirname(entryPath)); + const raw = JSON.parse(await readFile(packageJsonPath, "utf8")) as Record; + return { + packageJsonPath, + packageRoot: dirname(packageJsonPath), + packageJson: raw, + }; + } catch { + return null; + } +} + +export async function ensurePackageInstalled( + pkg: string, +): Promise { + const existing = await resolveInstalledPackage(pkg); + if (existing) return existing; + + const localRepo = await findLocalSensorRepo(pkg); + if (localRepo) { + await linkLocalPackage(pkg, localRepo); + const linked = await resolveInstalledPackage(pkg); + if (linked) return linked; + } + + await runCommand("npm", ["install", "--no-save", pkg], { + cwd: pluginPackageRoot(), + }); + const installed = await resolveInstalledPackage(pkg); + if (!installed) { + throw new Error(`Failed to resolve installed package ${pkg}`); + } + return installed; +} + +export async function maybeUninstallPackage( + pkg: string, +): Promise { + try { + await runCommand("npm", ["uninstall", "--no-save", pkg], { + cwd: pluginPackageRoot(), + }); + } catch { + // best effort + } +} + +export async function writeGeneratedSkill( + paths: World2AgentPaths, + pkg: string, + installed: InstalledPackageInfo, +): Promise { + const skillId = packageToSkillId(pkg); + const sourceType = String( + (installed.packageJson.w2a as Record | undefined)?.source_type ?? pkg, + ); + const signals = ( + (installed.packageJson.w2a as Record | undefined)?.signals as + | string[] + | undefined + )?.join(", "); + + const skillDir = join(paths.openclawSkillsDir, skillId); + await mkdir(skillDir, { recursive: true }); + const skillMd = [ + "---", + `name: ${skillId}`, + `description: Handle World2Agent signals from ${pkg}.`, + "user-invocable: false", + "---", + "", + `# ${skillId}`, + "", + `Handle W2A signals from \`${pkg}\` (source type: \`${sourceType}\`).`, + "", + "## Inputs", + "- The prompt body contains markdown context plus a fenced JSON copy of the full `signal` object.", + signals ? `- Common signal types: ${signals}` : "- Inspect `signal.event.type` for the exact event kind.", + "", + "## Behavior", + "- Parse the JSON when you need structured fields.", + "- If the signal is irrelevant or obviously low-value, skip silently.", + "- If it is actionable, reply briefly with the key fact, why it matters, and any obvious next step.", + "", + "## Notes", + "- This skill was generated by @world2agent/openclaw-plugin because the sensor package does not ship a richer OpenClaw-specific handler yet.", + "- If the dedicated World2Agent agent does not expose a skills allowlist, the plugin falls back to `Use skill: ` prompt routing.", + "", + ].join("\n"); + await writeFile(join(skillDir, "SKILL.md"), skillMd, "utf8"); + return skillId; +} + +export async function loadConfigFile( + configFile: string | undefined, + installed: InstalledPackageInfo, +): Promise> { + if (!configFile) { + const setupPath = String( + (installed.packageJson.w2a as Record | undefined)?.setup ?? "SETUP.md", + ); + throw new Error( + `Interactive setup is not implemented; use --config-file . Sensor guidance: ${join( + installed.packageRoot, + setupPath, + )}`, + ); + } + + const raw = JSON.parse(await readFile(configFile, "utf8")) as unknown; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error(`Config file must contain a JSON object: ${configFile}`); + } + return raw as Record; +} + +export async function runCommand( + command: string, + args: string[], + options: { + cwd?: string; + env?: NodeJS.ProcessEnv; + } = {}, +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env ?? process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolvePromise({ stdout, stderr }); + return; + } + reject( + new Error( + `${command} ${args.join(" ")} failed with code ${code}: ${ + stderr.trim() || stdout.trim() + }`, + ), + ); + }); + }); +} + +async function findLocalSensorRepo(pkg: string): Promise { + if (!pkg.startsWith("@world2agent/sensor-")) return null; + + const slug = pkg.split("/").pop()?.replace(/^sensor-/, ""); + if (!slug) return null; + + const candidate = resolve(pluginPackageRoot(), "..", "..", "world2agent-sensors", slug); + return (await pathExists(join(candidate, "package.json"))) ? candidate : null; +} + +async function linkLocalPackage(pkg: string, sourceDir: string): Promise { + const scope = pkg.split("/")[0]; + const name = pkg.split("/")[1]; + if (!scope || !name) { + throw new Error(`Invalid package name: ${pkg}`); + } + + const target = join(pluginPackageRoot(), "node_modules", scope, name); + await mkdir(dirname(target), { recursive: true }); + await removePath(target); + await symlink(sourceDir, target, "dir"); +} + +async function findNearestPackageJson(startDir: string): Promise { + let current = startDir; + for (;;) { + const candidate = join(current, "package.json"); + if (await pathExists(candidate)) { + return candidate; + } + const parent = dirname(current); + if (parent === current) { + throw new Error(`Could not find package.json above ${startDir}`); + } + current = parent; + } +} + diff --git a/openclaw-plugin/src/isolated.ts b/openclaw-plugin/src/isolated.ts new file mode 100644 index 0000000..0fc6093 --- /dev/null +++ b/openclaw-plugin/src/isolated.ts @@ -0,0 +1,240 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { once } from "node:events"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import { buildIsolatedRunnerEnv, hashConfig, shouldRestartIsolatedHandle } from "./supervisor/shared.js"; +import type { + ApplyResult, + IsolatedRunnerHandle, + RequiredWorld2AgentPluginConfig, + SensorEntry, + World2AgentPaths, +} from "./types.js"; + +const NO_RESTART_EXIT_CODES = new Set([0, 10, 11, 12]); + +interface ChildHandle extends IsolatedRunnerHandle { + webhookUrl: string; + process: ChildProcessWithoutNullStreams; + stopping: boolean; + lastExitCode: number | null; + restartCount: number; +} + +export interface IsolatedRunnerManagerOptions { + paths: World2AgentPaths; + pluginConfig: RequiredWorld2AgentPluginConfig; + ingestUrl?: string; + hmacSecret: string; + log: (line: string) => void; +} + +export class IsolatedRunnerManager { + private readonly options: IsolatedRunnerManagerOptions; + private readonly handles = new Map(); + private readonly desiredEntries = new Map(); + private readonly runnerBin = fileURLToPath(new URL("./runner/bin.js", import.meta.url)); + + constructor(options: IsolatedRunnerManagerOptions) { + this.options = options; + } + + async apply(entries: SensorEntry[]): Promise { + const desired = entries.filter((entry) => entry.enabled !== false && entry.isolated === true); + const result: ApplyResult = { + started: [], + restarted: [], + stopped: [], + failed: [], + }; + + this.desiredEntries.clear(); + for (const entry of desired) { + this.desiredEntries.set(entry.sensor_id, entry); + } + + for (const [sensorId, handle] of [...this.handles.entries()]) { + if (!this.desiredEntries.has(sensorId)) { + await this.terminate(handle); + result.stopped.push(sensorId); + } + } + + for (const entry of desired) { + if (!this.options.ingestUrl) { + result.failed.push({ + sensor_id: entry.sensor_id, + error: + "isolated runner requires plugin config `ingestUrl` so the subprocess can POST /w2a/ingest", + }); + continue; + } + + const existing = this.handles.get(entry.sensor_id); + if (!existing) { + try { + await this.spawn(entry); + result.started.push(entry.sensor_id); + } catch (error) { + result.failed.push({ sensor_id: entry.sensor_id, error: errorMessage(error) }); + } + continue; + } + + if (!shouldRestartIsolatedHandle(existing, entry, this.options.ingestUrl)) { + continue; + } + + try { + await this.terminate(existing); + await this.spawn(entry); + result.restarted.push(entry.sensor_id); + } catch (error) { + result.failed.push({ sensor_id: entry.sensor_id, error: errorMessage(error) }); + } + } + + return result; + } + + async terminateAll(graceMs = 5_000): Promise { + this.desiredEntries.clear(); + for (const handle of [...this.handles.values()]) { + await this.terminate(handle, graceMs); + } + } + + private async spawn(entry: SensorEntry, restartCount = 0): Promise { + if (!this.options.ingestUrl) { + throw new Error( + "isolated runner requires plugin config `ingestUrl` so the subprocess can POST /w2a/ingest", + ); + } + + const proc = spawn(process.execPath, [this.runnerBin], { + env: buildIsolatedRunnerEnv({ + pkg: entry.pkg, + sensorId: entry.sensor_id, + skillId: entry.skill_id, + ingestUrl: this.options.ingestUrl, + hmacSecret: this.options.hmacSecret, + statePath: join(this.options.paths.stateDir, `${entry.sensor_id}.json`), + }), + stdio: ["pipe", "pipe", "pipe"], + }); + + const handle: ChildHandle = { + sensorId: entry.sensor_id, + pkg: entry.pkg, + skillId: entry.skill_id, + isolated: true, + configHash: hashConfig(entry.config), + startedAt: Date.now(), + cleanup: async () => { + await this.terminate(handle); + }, + webhookUrl: this.options.ingestUrl, + process: proc, + stopping: false, + lastExitCode: null, + restartCount, + }; + + this.handles.set(entry.sensor_id, handle); + proc.on("exit", (code, signal) => { + void this.handleExit(handle, code, signal); + }); + pipeStream(proc.stdout, (line) => this.options.log(`[w2a/${entry.sensor_id}] ${line}`)); + pipeStream(proc.stderr, (line) => this.options.log(`[w2a/${entry.sensor_id}] ${line}`)); + proc.stdin.end(JSON.stringify(entry.config ?? {}) + "\n"); + + return handle; + } + + private async terminate(handle: ChildHandle, graceMs = 5_000): Promise { + handle.stopping = true; + if (handle.process.exitCode !== null || handle.process.killed) { + this.handles.delete(handle.sensorId); + return; + } + + const exitPromise = once(handle.process, "exit").catch(() => []); + try { + handle.process.kill("SIGTERM"); + } catch { + this.handles.delete(handle.sensorId); + return; + } + + const timedOut = await Promise.race([ + exitPromise.then(() => false), + delay(graceMs).then(() => true), + ]); + if (timedOut) { + try { + handle.process.kill("SIGKILL"); + } catch { + // no-op + } + await exitPromise; + } + + this.handles.delete(handle.sensorId); + } + + private async handleExit( + handle: ChildHandle, + code: number | null, + signal: NodeJS.Signals | null, + ): Promise { + handle.lastExitCode = code; + + const current = this.handles.get(handle.sensorId); + if (current !== handle) return; + this.handles.delete(handle.sensorId); + this.options.log( + `[w2a/${handle.sensorId}] isolated runner exited code=${String(code)} signal=${String(signal)}`, + ); + + if (handle.stopping) return; + if (code !== null && NO_RESTART_EXIT_CODES.has(code)) return; + + const nextEntry = this.desiredEntries.get(handle.sensorId); + if (!nextEntry) return; + if (!this.options.ingestUrl) return; + + try { + await this.spawn(nextEntry, handle.restartCount + 1); + } catch (error) { + this.options.log( + `[w2a/${handle.sensorId}] isolated restart failed: ${errorMessage(error)}`, + ); + } + } +} + +function pipeStream( + stream: NodeJS.ReadableStream, + onLine: (line: string) => void, +): void { + stream.setEncoding("utf8"); + let buffer = ""; + stream.on("data", (chunk: string) => { + buffer += chunk; + for (;;) { + const index = buffer.indexOf("\n"); + if (index === -1) break; + const line = buffer.slice(0, index).trimEnd(); + buffer = buffer.slice(index + 1); + if (line) onLine(line); + } + }); +} + +function delay(ms: number): Promise { + return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/openclaw-plugin/src/manifest.ts b/openclaw-plugin/src/manifest.ts new file mode 100644 index 0000000..6109759 --- /dev/null +++ b/openclaw-plugin/src/manifest.ts @@ -0,0 +1,176 @@ +import { createHash } from "node:crypto"; +import { access, readFile, rm } from "node:fs/promises"; +import { packageToSkillId } from "@world2agent/sdk"; +import { ensureWorld2AgentDirs, writeTextAtomic } from "./paths.js"; +import type { SensorEntry, SensorManifest, World2AgentPaths } from "./types.js"; + +const DEFAULT_MANIFEST: SensorManifest = { + version: 1, + sensors: [], +}; + +export async function readManifest(paths: World2AgentPaths): Promise { + try { + const raw = await readFile(paths.manifestFile, "utf8"); + return parseManifest(JSON.parse(raw) as unknown); + } catch (error) { + if (isMissingFile(error)) { + return structuredClone(DEFAULT_MANIFEST); + } + throw error; + } +} + +export async function writeManifest( + paths: World2AgentPaths, + manifest: SensorManifest, +): Promise { + await ensureWorld2AgentDirs(paths); + const normalized: SensorManifest = { + version: 1, + sensors: manifest.sensors.map(normalizeSensorEntry), + }; + await writeTextAtomic(paths.manifestFile, JSON.stringify(normalized, null, 2) + "\n"); +} + +export function upsertSensorEntry( + manifest: SensorManifest, + entry: SensorEntry, +): SensorManifest { + const normalized = normalizeSensorEntry(entry); + const sensors = manifest.sensors.filter((item) => item.sensor_id !== normalized.sensor_id); + sensors.push(normalized); + sensors.sort((a, b) => a.sensor_id.localeCompare(b.sensor_id)); + return { + version: 1, + sensors, + }; +} + +export function removeSensorEntry( + manifest: SensorManifest, + sensorId: string, +): { + manifest: SensorManifest; + removed: SensorEntry | null; +} { + const removed = manifest.sensors.find((entry) => entry.sensor_id === sensorId) ?? null; + return { + manifest: { + version: 1, + sensors: manifest.sensors.filter((entry) => entry.sensor_id !== sensorId), + }, + removed, + }; +} + +export function normalizeSensorEntry(entry: SensorEntry): SensorEntry { + return { + sensor_id: entry.sensor_id, + pkg: entry.pkg, + skill_id: packageToSkillId(entry.pkg), + enabled: entry.enabled !== false, + isolated: entry.isolated === true, + config: entry.config ?? {}, + }; +} + +export function defaultSensorId(pkg: string): string { + const suffix = pkg.split("/").pop() ?? pkg; + return suffix.replace(/^sensor-/, ""); +} + +export function stableStringify(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(",")}]`; + } + const obj = value as Record; + return `{${Object.keys(obj) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`) + .join(",")}}`; +} + +export function hashConfig(config: unknown): string { + return createHash("sha1").update(stableStringify(config)).digest("hex"); +} + +export async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +export async function removePath(path: string): Promise { + await rm(path, { force: true, recursive: true }); +} + +function parseManifest(raw: unknown): SensorManifest { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("Manifest must be a JSON object"); + } + + const version = (raw as Record).version; + const sensors = (raw as Record).sensors; + if (version !== 1) { + throw new Error(`Unsupported manifest version: ${String(version)}`); + } + if (!Array.isArray(sensors)) { + throw new Error("Manifest field `sensors` must be an array"); + } + + return { + version: 1, + sensors: sensors.map((entry, index) => parseSensorEntry(entry, index)), + }; +} + +function parseSensorEntry(raw: unknown, index: number): SensorEntry { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error(`Manifest sensor[${index}] must be an object`); + } + + const entry = raw as Record; + const sensorId = expectString(entry.sensor_id, `sensor[${index}].sensor_id`); + const pkg = expectString(entry.pkg, `sensor[${index}].pkg`); + const enabled = entry.enabled === undefined ? true : Boolean(entry.enabled); + const isolated = entry.isolated === true; + const config = entry.config; + if (!config || typeof config !== "object" || Array.isArray(config)) { + throw new Error(`sensor[${index}].config must be an object`); + } + + return { + sensor_id: sensorId, + pkg, + skill_id: + entry.skill_id === undefined + ? packageToSkillId(pkg) + : expectString(entry.skill_id, `sensor[${index}].skill_id`), + enabled, + isolated, + config: config as Record, + }; +} + +function expectString(value: unknown, label: string): string { + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`${label} must be a non-empty string`); + } + return value; +} + +function isMissingFile(error: unknown): boolean { + return isNodeError(error) && error.code === "ENOENT"; +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + diff --git a/openclaw-plugin/src/openclaw/plugin-sdk/plugin-entry.ts b/openclaw-plugin/src/openclaw/plugin-sdk/plugin-entry.ts new file mode 100644 index 0000000..86b88da --- /dev/null +++ b/openclaw-plugin/src/openclaw/plugin-sdk/plugin-entry.ts @@ -0,0 +1,6 @@ +import type { OpenClawPluginEntry } from "./types.js"; + +export function definePluginEntry(entry: T): T { + return entry; +} + diff --git a/openclaw-plugin/src/openclaw/plugin-sdk/types.ts b/openclaw-plugin/src/openclaw/plugin-sdk/types.ts new file mode 100644 index 0000000..e96b32a --- /dev/null +++ b/openclaw-plugin/src/openclaw/plugin-sdk/types.ts @@ -0,0 +1,135 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; + +export interface EmbeddedAgentRunRequest { + sessionId: string; + sessionKey?: string; + agentId?: string; + runId: string; + sessionFile: string; + workspaceDir: string; + agentDir?: string; + config?: OpenClawConfig; + prompt: string; + timeoutMs?: number; + [key: string]: unknown; +} + +export interface OpenClawAgentConfig { + id?: string; + name?: string; + skills?: string[]; + [key: string]: unknown; +} + +export interface OpenClawAgentDefaults { + contextInjection?: string; + model?: { + primary?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export interface OpenClawConfig { + agents?: { + defaults?: OpenClawAgentDefaults; + list?: OpenClawAgentConfig[]; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export interface OpenClawPluginConfig { + sensorsManifestPath?: string; + stateDir?: string; + sessionDir?: string; + workspaceDir?: string; + ingestUrl?: string; + defaultAgentId?: string; + requestTimeoutMs?: number; + ingestHmacSecretFile?: string; + ingestDedupTtlMs?: number; +} + +export interface OpenClawPluginLogger { + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; + debug(message: string, ...args: unknown[]): void; +} + +export interface OpenClawRuntimeAgentSessionApi { + resolveSessionFilePath?(config: OpenClawConfig, sessionId: string): string; +} + +export interface OpenClawRuntimeAgentApi { + runEmbeddedAgent?(request: EmbeddedAgentRunRequest): Promise; + resolveAgentDir?(config: OpenClawConfig, agentId?: string): string; + resolveAgentWorkspaceDir?(config: OpenClawConfig, agentId?: string): string; + resolveAgentTimeoutMs?(config: OpenClawConfig): number; + session?: OpenClawRuntimeAgentSessionApi; +} + +export interface OpenClawRuntimeConfigApi { + loadConfig?(): Promise; + writeConfigFile?(config: OpenClawConfig): Promise; +} + +export interface CliCommandBuilder { + description(text: string): CliCommandBuilder; + option(flags: string, description?: string): CliCommandBuilder; + command(name: string): CliCommandBuilder; + action(handler: (...args: any[]) => unknown): CliCommandBuilder; +} + +export interface CliProgram { + command(name: string): CliCommandBuilder; +} + +export type CliRegistrar = (context: { program: CliProgram }) => Promise | void; + +export interface CliCommandDescriptor { + name: string; + description?: string; + hasSubcommands?: boolean; +} + +export interface OpenClawGatewayMethodContext { + payload?: unknown; +} + +export type OpenClawGatewayMethodHandler = ( + context?: OpenClawGatewayMethodContext, +) => Promise | unknown; + +export interface OpenClawHttpRouteRegistration { + path: string; + auth: "plugin"; + handler: (req: IncomingMessage, res: ServerResponse) => Promise | void; +} + +export interface OpenClawPluginApi { + config?: OpenClawConfig; + pluginConfig?: unknown; + registrationMode?: string; + logger?: OpenClawPluginLogger; + resolvePath?(value: string): string; + registerCli?( + registrar: CliRegistrar, + options?: { descriptors?: CliCommandDescriptor[] }, + ): void; + registerGatewayMethod?( + name: string, + handler: OpenClawGatewayMethodHandler, + ): void; + registerHttpRoute?(route: OpenClawHttpRouteRegistration): void; + runtime?: { + agent?: OpenClawRuntimeAgentApi; + config?: OpenClawRuntimeConfigApi; + }; +} + +export interface OpenClawPluginEntry { + id: string; + register(api: OpenClawPluginApi): Promise | void; +} diff --git a/openclaw-plugin/src/paths.ts b/openclaw-plugin/src/paths.ts new file mode 100644 index 0000000..db9e90e --- /dev/null +++ b/openclaw-plugin/src/paths.ts @@ -0,0 +1,93 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { + mkdirSync, + readFileSync, + writeFileSync, + existsSync, + renameSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { randomBytes } from "node:crypto"; +import type { RequiredWorld2AgentPluginConfig, World2AgentPaths } from "./types.js"; + +export function getWorld2AgentPaths( + pluginConfig: RequiredWorld2AgentPluginConfig, + env: NodeJS.ProcessEnv = process.env, +): World2AgentPaths { + const openclawHome = env.OPENCLAW_HOME ?? join(homedir(), ".openclaw"); + const baseDir = env.W2A_HOME ?? join(homedir(), ".world2agent"); + + return { + baseDir, + manifestFile: resolvePath(baseDir, pluginConfig.sensorsManifestPath, "sensors.json"), + stateDir: resolvePath(baseDir, pluginConfig.stateDir, "state"), + sessionDir: resolvePath(baseDir, pluginConfig.sessionDir, "sessions"), + openclawHome, + openclawSkillsDir: join(openclawHome, "skills"), + ingestHmacSecretFile: resolvePath( + baseDir, + pluginConfig.ingestHmacSecretFile, + ".openclaw-ingest-secret", + ), + }; +} + +export async function ensureWorld2AgentDirs( + paths: World2AgentPaths, +): Promise { + await mkdir(paths.baseDir, { recursive: true }); + await mkdir(paths.stateDir, { recursive: true }); + await mkdir(paths.sessionDir, { recursive: true }); + await mkdir(paths.openclawSkillsDir, { recursive: true }); +} + +// OpenClaw's plugin loader does NOT await async register(); we must do +// pre-register filesystem work synchronously. +export function ensureWorld2AgentDirsSync(paths: World2AgentPaths): void { + mkdirSync(paths.baseDir, { recursive: true }); + mkdirSync(paths.stateDir, { recursive: true }); + mkdirSync(paths.sessionDir, { recursive: true }); + mkdirSync(paths.openclawSkillsDir, { recursive: true }); +} + +export function loadOrCreateHmacSecretSync(path: string): string { + if (existsSync(path)) { + const existing = readFileSync(path, "utf8").trim(); + if (existing) return existing; + } + mkdirSync(dirname(path), { recursive: true }); + const next = randomBytes(32).toString("hex"); + const tmp = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tmp, `${next}\n`); + renameSync(tmp, path); + return next; +} + +export async function readTrimmedText(path: string): Promise { + try { + return (await readFile(path, "utf8")).trim() || null; + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") return null; + throw error; + } +} + +export async function writeTextAtomic( + path: string, + content: string, +): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmp, content, "utf8"); + await rename(tmp, path); +} + +function resolvePath(baseDir: string, override: string | undefined, fallback: string): string { + return override ? resolve(override) : join(baseDir, fallback); +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + diff --git a/openclaw-plugin/src/plugin.ts b/openclaw-plugin/src/plugin.ts new file mode 100644 index 0000000..c4deb15 --- /dev/null +++ b/openclaw-plugin/src/plugin.ts @@ -0,0 +1,117 @@ +import { EmbeddedDispatcher, HttpDispatcher } from "./dispatch.js"; +import { + assertContextInjectionCompatible, + loadEffectiveOpenClawConfig, + normalizePluginConfig, +} from "./config.js"; +import { registerWorld2AgentCli } from "./cli.js"; +import { readManifest } from "./manifest.js"; +import { + ensureWorld2AgentDirsSync, + loadOrCreateHmacSecretSync, +} from "./paths.js"; +import { definePluginEntry } from "./openclaw/plugin-sdk/plugin-entry.js"; +import type { OpenClawPluginApi } from "./openclaw/plugin-sdk/types.js"; +import { IsolatedRunnerManager } from "./isolated.js"; +import { SensorRuntime } from "./runtime.js"; +import { getWorld2AgentPaths } from "./paths.js"; + +// register() MUST stay synchronous: OpenClaw's plugin loader logs +// "plugin register returned a promise; async registration is ignored" +// and drops every api.register* call that follows an await. +export function createWorld2AgentPlugin() { + return definePluginEntry({ + id: "world2agent", + register(api: OpenClawPluginApi): void { + const pluginConfig = normalizePluginConfig(api.pluginConfig); + const paths = getWorld2AgentPaths(pluginConfig); + ensureWorld2AgentDirsSync(paths); + + registerWorld2AgentCli({ + api, + paths, + pluginConfig, + }); + + if ((api.registrationMode ?? "full") === "cli-metadata") { + return; + } + + const openclawConfig = api.config ?? {}; + assertContextInjectionCompatible(openclawConfig); + const openclawConfigRef = { current: openclawConfig }; + + const embeddedDispatcher = new EmbeddedDispatcher({ + api, + openclawConfigRef, + pluginConfig, + paths, + }); + + const hmacSecret = loadOrCreateHmacSecretSync(paths.ingestHmacSecretFile); + const httpDispatcher = new HttpDispatcher({ + embeddedDispatcher, + hmacSecret, + dedupTtlMs: pluginConfig.ingestDedupTtlMs, + }); + + api.registerHttpRoute?.(httpDispatcher.createRoute()); + + const runtime = new SensorRuntime({ + dispatcher: embeddedDispatcher, + isolatedRunnerManager: new IsolatedRunnerManager({ + paths, + pluginConfig, + ingestUrl: pluginConfig.ingestUrl, + hmacSecret, + log: (line) => log(api, line), + }), + paths, + log: (line) => log(api, line), + }); + + api.registerGatewayMethod?.("world2agent.reload", async () => { + const nextConfig = await loadEffectiveOpenClawConfig(api); + assertContextInjectionCompatible(nextConfig); + openclawConfigRef.current = nextConfig; + const manifest = await readManifest(paths); + return { + ok: true, + applied: await runtime.applyManifest(manifest.sensors), + }; + }); + + if ((api.registrationMode ?? "full") !== "full") { + return; + } + + // Fire-and-forget: must not be awaited because register() itself is sync. + void runStartup({ api, runtime, paths }); + }, + }); +} + +async function runStartup(opts: { + api: OpenClawPluginApi; + runtime: SensorRuntime; + paths: ReturnType; +}): Promise { + try { + const manifest = await readManifest(opts.paths); + const applied = await opts.runtime.applyManifest(manifest.sensors); + if (applied.failed.length > 0) { + log( + opts.api, + `[w2a] startup completed with failures: ${JSON.stringify(applied.failed)}`, + ); + } + } catch (error) { + const logger = opts.api.logger ?? console; + logger.error("[w2a] startup failed:", error); + } +} + +function log(api: OpenClawPluginApi, line: string): void { + const logger = api.logger ?? console; + logger.info(line); +} diff --git a/openclaw-plugin/src/prompt.ts b/openclaw-plugin/src/prompt.ts new file mode 100644 index 0000000..d78772a --- /dev/null +++ b/openclaw-plugin/src/prompt.ts @@ -0,0 +1,44 @@ +import type { Attachment, W2ASignal } from "@world2agent/sdk"; + +export function renderSignalPrompt( + signal: W2ASignal, + options: { + skillId: string; + useSkillPrefix: boolean; + }, +): string { + const attachmentLines = renderAttachmentLines(signal.attachments ?? []); + const body = [ + "# World2Agent Signal", + "", + `Event: ${signal.event.type}`, + signal.event.summary, + attachmentLines ? "" : null, + attachmentLines || null, + "", + "Signal JSON:", + "```json", + JSON.stringify(signal, null, 2), + "```", + ] + .filter((part): part is string => part !== null) + .join("\n"); + + if (!options.useSkillPrefix) { + return body; + } + + return `Use skill: ${options.skillId}\n\n${body}`; +} + +function renderAttachmentLines(attachments: Attachment[]): string { + if (attachments.length === 0) return ""; + + const lines = attachments.map((attachment) => { + const locator = attachment.type === "reference" ? attachment.uri : "inline"; + return `- ${attachment.mime_type} ${attachment.description} (${locator})`; + }); + + return ["Attachments:", ...lines].join("\n"); +} + diff --git a/openclaw-plugin/src/runner/bin.ts b/openclaw-plugin/src/runner/bin.ts new file mode 100644 index 0000000..4e4046f --- /dev/null +++ b/openclaw-plugin/src/runner/bin.ts @@ -0,0 +1,129 @@ +#!/usr/bin/env node + +import { FileSensorStore, startSensor, type SensorSpec } from "@world2agent/sdk"; +import { pathToFileURL } from "node:url"; +import { isAbsolute, resolve } from "node:path"; +import { ingestTransport } from "./http-transport.js"; +import { readJsonFromStdin } from "./config-stream.js"; + +const EXIT_CONFIG_ERROR = 10; +const EXIT_IMPORT_ERROR = 11; +const EXIT_START_ERROR = 12; + +async function main(): Promise { + const env = requireEnv([ + "W2A_PACKAGE", + "W2A_INGEST_URL", + "W2A_HMAC_SECRET", + "W2A_SENSOR_ID", + "W2A_SKILL_ID", + "W2A_STATE_PATH", + ]); + + let config: Record; + try { + config = await readJsonFromStdin(); + } catch (error) { + console.error(error); + process.exit(EXIT_CONFIG_ERROR); + } + + let spec: SensorSpec>; + try { + spec = await loadSensorSpec(env.W2A_PACKAGE); + } catch (error) { + console.error(error); + process.exit(EXIT_IMPORT_ERROR); + } + + const transport = ingestTransport({ + url: env.W2A_INGEST_URL, + hmacSecret: env.W2A_HMAC_SECRET, + sensorId: env.W2A_SENSOR_ID, + skillId: env.W2A_SKILL_ID, + timeoutMs: 120_000, + }); + const store = new FileSensorStore({ path: env.W2A_STATE_PATH }); + + let cleanup: (() => Promise | void) | undefined; + try { + cleanup = await startSensor(spec, { + config, + onSignal: transport, + store, + logger: console, + logEmits: true, + }); + } catch (error) { + console.error(error); + await store.flush().catch(() => {}); + process.exit(EXIT_START_ERROR); + } + + let shuttingDown = false; + const shutdown = async () => { + if (shuttingDown) return; + shuttingDown = true; + + try { + await cleanup?.(); + await store.flush(); + } catch (error) { + console.error(error); + process.exit(1); + } + + process.exit(0); + }; + + process.on("SIGTERM", () => { + void shutdown(); + }); + process.on("SIGINT", () => { + void shutdown(); + }); + + const watchdog = setInterval(() => { + if (process.ppid === 1) { + console.error("[w2a-openclaw-runner] parent died; shutting down"); + void shutdown(); + } + }, 5_000); + watchdog.unref(); + + await new Promise(() => {}); +} + +async function loadSensorSpec(pkg: string): Promise>> { + const module = await import(resolveImportTarget(pkg)); + const spec = module.default as SensorSpec> | undefined; + if (!spec || typeof spec.start !== "function") { + throw new Error(`${pkg} does not export a valid default SensorSpec`); + } + return spec; +} + +function resolveImportTarget(pkg: string): string { + if (pkg.startsWith(".") || pkg.startsWith("/") || isAbsolute(pkg)) { + return pathToFileURL(resolve(pkg)).href; + } + return pkg; +} + +function requireEnv(keys: string[]): Record { + const values: Record = {}; + for (const key of keys) { + const value = process.env[key]; + if (!value) { + throw new Error(`Missing required env var: ${key}`); + } + values[key] = value; + } + return values; +} + +main().catch((error) => { + console.error(error); + process.exit(99); +}); + diff --git a/openclaw-plugin/src/runner/config-stream.ts b/openclaw-plugin/src/runner/config-stream.ts new file mode 100644 index 0000000..f0170d0 --- /dev/null +++ b/openclaw-plugin/src/runner/config-stream.ts @@ -0,0 +1,4 @@ +import { readJsonFromStdin } from "../supervisor/shared.js"; + +export { readJsonFromStdin }; + diff --git a/openclaw-plugin/src/runner/http-transport.ts b/openclaw-plugin/src/runner/http-transport.ts new file mode 100644 index 0000000..5b369db --- /dev/null +++ b/openclaw-plugin/src/runner/http-transport.ts @@ -0,0 +1,64 @@ +import { createHmac } from "node:crypto"; +import type { W2ASignal } from "@world2agent/sdk"; +import type { HttpIngestEnvelope } from "../types.js"; + +export interface IngestTransportOptions { + url: string; + hmacSecret: string; + sensorId: string; + skillId: string; + timeoutMs: number; + retries?: number; + retryDelayMs?: number; +} + +export function ingestTransport(options: IngestTransportOptions) { + const retries = options.retries ?? 2; + const retryDelayMs = options.retryDelayMs ?? 500; + + return async (signal: W2ASignal): Promise => { + const envelope: HttpIngestEnvelope = { + sensor_id: options.sensorId, + skill_id: options.skillId, + signal, + }; + const body = JSON.stringify(envelope); + const headers: Record = { + "Content-Type": "application/json", + "X-Request-ID": signal.signal_id, + "X-Webhook-Signature": createHmac("sha256", options.hmacSecret) + .update(body) + .digest("hex"), + }; + + let lastError: unknown; + for (let attempt = 0; attempt <= retries; attempt += 1) { + try { + const response = await fetch(options.url, { + method: "POST", + headers, + body, + signal: AbortSignal.timeout(options.timeoutMs), + }); + if (response.ok) return; + if (response.status >= 400 && response.status < 500) { + throw new Error(`HTTP ${response.status}: ${await response.text().catch(() => "")}`); + } + lastError = new Error(`HTTP ${response.status}`); + } catch (error) { + lastError = error; + } + + if (attempt < retries) { + await sleep(retryDelayMs * 2 ** attempt); + } + } + + throw lastError; + }; +} + +function sleep(ms: number): Promise { + return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); +} + diff --git a/openclaw-plugin/src/runtime.ts b/openclaw-plugin/src/runtime.ts new file mode 100644 index 0000000..dd9785b --- /dev/null +++ b/openclaw-plugin/src/runtime.ts @@ -0,0 +1,160 @@ +import { FileSensorStore, startSensor, type SensorSpec } from "@world2agent/sdk"; +import { join } from "node:path"; +import type { Dispatcher, ApplyResult, RuntimeHandle, SensorEntry, World2AgentPaths } from "./types.js"; +import { hashConfig } from "./manifest.js"; +import { IsolatedRunnerManager } from "./isolated.js"; +import { resolveImportTarget } from "./supervisor/shared.js"; + +export interface SensorRuntimeOptions { + dispatcher: Dispatcher; + isolatedRunnerManager: IsolatedRunnerManager; + paths: World2AgentPaths; + log: (line: string) => void; +} + +export class SensorRuntime { + private readonly dispatcher: Dispatcher; + private readonly isolatedRunnerManager: IsolatedRunnerManager; + private readonly paths: World2AgentPaths; + private readonly log: (line: string) => void; + private readonly handles = new Map(); + + constructor(options: SensorRuntimeOptions) { + this.dispatcher = options.dispatcher; + this.isolatedRunnerManager = options.isolatedRunnerManager; + this.paths = options.paths; + this.log = options.log; + } + + async applyManifest(entries: SensorEntry[]): Promise { + const desired = entries.filter((entry) => entry.enabled !== false); + const result: ApplyResult = { + started: [], + restarted: [], + stopped: [], + failed: [], + }; + + const desiredInProcess = desired.filter((entry) => entry.isolated !== true); + + for (const [sensorId, handle] of [...this.handles.entries()]) { + if (!desiredInProcess.some((entry) => entry.sensor_id === sensorId)) { + await this.stopHandle(handle); + result.stopped.push(sensorId); + } + } + + for (const entry of desiredInProcess) { + const existing = this.handles.get(entry.sensor_id); + if (!existing) { + try { + await this.startHandle(entry); + result.started.push(entry.sensor_id); + } catch (error) { + result.failed.push({ sensor_id: entry.sensor_id, error: errorMessage(error) }); + } + continue; + } + + if (matchesHandle(existing, entry)) { + continue; + } + + try { + await this.stopHandle(existing); + await this.startHandle(entry); + result.restarted.push(entry.sensor_id); + } catch (error) { + result.failed.push({ sensor_id: entry.sensor_id, error: errorMessage(error) }); + } + } + + const isolatedResult = await this.isolatedRunnerManager.apply(desired); + mergeApplyResult(result, isolatedResult); + + return result; + } + + async stopAll(): Promise { + for (const handle of [...this.handles.values()]) { + await this.stopHandle(handle); + } + await this.isolatedRunnerManager.terminateAll(); + } + + private async startHandle(entry: SensorEntry): Promise { + const spec = await loadSensorSpec(entry.pkg); + const store = new FileSensorStore({ + path: join(this.paths.stateDir, `${entry.sensor_id}.json`), + }); + const cleanup = await startSensor(spec, { + config: entry.config, + store, + logger: console, + logEmits: true, + onSignal: async (signal) => { + try { + await this.dispatcher.dispatch({ + sensorId: entry.sensor_id, + skillId: entry.skill_id, + signal, + }); + } catch (error) { + this.log( + `[w2a/${entry.sensor_id}] dispatch failed: ${errorMessage(error)}`, + ); + } + }, + }); + + const handle: RuntimeHandle = { + sensorId: entry.sensor_id, + pkg: entry.pkg, + skillId: entry.skill_id, + isolated: false, + configHash: hashConfig(entry.config), + startedAt: Date.now(), + cleanup, + flush: () => store.flush(), + }; + this.handles.set(entry.sensor_id, handle); + } + + private async stopHandle(handle: RuntimeHandle): Promise { + try { + await handle.cleanup(); + await handle.flush?.(); + } finally { + this.handles.delete(handle.sensorId); + } + } +} + +async function loadSensorSpec(pkg: string): Promise>> { + const module = await import(resolveImportTarget(pkg)); + const spec = module.default as SensorSpec> | undefined; + if (!spec || typeof spec.start !== "function") { + throw new Error(`${pkg} does not export a valid default SensorSpec`); + } + return spec; +} + +function matchesHandle(handle: RuntimeHandle, entry: SensorEntry): boolean { + return ( + handle.pkg === entry.pkg && + handle.skillId === entry.skill_id && + handle.configHash === hashConfig(entry.config) && + handle.isolated === false + ); +} + +function mergeApplyResult(target: ApplyResult, update: ApplyResult): void { + target.started.push(...update.started); + target.restarted.push(...update.restarted); + target.stopped.push(...update.stopped); + target.failed.push(...update.failed); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/openclaw-plugin/src/supervisor/shared.ts b/openclaw-plugin/src/supervisor/shared.ts new file mode 100644 index 0000000..e61df45 --- /dev/null +++ b/openclaw-plugin/src/supervisor/shared.ts @@ -0,0 +1,117 @@ +import { createHash } from "node:crypto"; +import { isAbsolute, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { SensorEntry } from "../types.js"; + +/** + * Copied in minimal form from hermes-sensor-bridge: + * - src/supervisor/manifest.ts + * - src/supervisor/spawn.ts + * - src/runner/config-stream.ts + * - src/runner/bin.ts + */ + +export interface IsolatedProcessMeta { + sensorId: string; + pkg: string; + skillId: string; + webhookUrl: string; + configHash: string; +} + +export interface IsolatedRunnerEnvInput { + pkg: string; + sensorId: string; + skillId: string; + ingestUrl: string; + hmacSecret: string; + statePath: string; + logLevel?: string; +} + +export function buildIsolatedRunnerEnv( + input: IsolatedRunnerEnvInput, +): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === "string") { + env[key] = value; + } + } + + return { + ...env, + W2A_PACKAGE: input.pkg, + W2A_INGEST_URL: input.ingestUrl, + W2A_HMAC_SECRET: input.hmacSecret, + W2A_SENSOR_ID: input.sensorId, + W2A_SKILL_ID: input.skillId, + W2A_STATE_PATH: input.statePath, + W2A_LOG_LEVEL: input.logLevel ?? process.env.W2A_LOG_LEVEL ?? "info", + }; +} + +export function shouldRestartIsolatedHandle( + handle: IsolatedProcessMeta, + entry: SensorEntry, + ingestUrl: string, +): boolean { + return !( + handle.pkg === entry.pkg && + handle.skillId === entry.skill_id && + handle.webhookUrl === ingestUrl && + handle.configHash === hashConfig(entry.config) + ); +} + +export async function readJsonFromStdin( + stdin: AsyncIterable = process.stdin, +): Promise> { + let raw = ""; + for await (const chunk of stdin) { + raw += chunk.toString(); + } + + const text = raw.trim(); + if (!text) return {}; + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new Error( + `Invalid sensor config JSON on stdin: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Sensor config JSON must be an object"); + } + + return parsed as Record; +} + +export function resolveImportTarget(pkg: string): string { + if (pkg.startsWith(".") || pkg.startsWith("/") || isAbsolute(pkg)) { + return pathToFileURL(resolve(pkg)).href; + } + return pkg; +} + +export function hashConfig(config: unknown): string { + return createHash("sha1").update(stableStringify(config)).digest("hex"); +} + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(",")}]`; + } + const obj = value as Record; + return `{${Object.keys(obj) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`) + .join(",")}}`; +} diff --git a/openclaw-plugin/src/types.ts b/openclaw-plugin/src/types.ts new file mode 100644 index 0000000..ec8a10b --- /dev/null +++ b/openclaw-plugin/src/types.ts @@ -0,0 +1,104 @@ +import type { CleanupFn, W2ASignal } from "@world2agent/sdk"; +import type { + OpenClawConfig, + OpenClawPluginApi, + OpenClawPluginConfig, +} from "./openclaw/plugin-sdk/types.js"; + +export interface World2AgentPaths { + baseDir: string; + manifestFile: string; + stateDir: string; + sessionDir: string; + openclawHome: string; + openclawSkillsDir: string; + ingestHmacSecretFile: string; +} + +export interface SensorEntry { + sensor_id: string; + pkg: string; + skill_id: string; + enabled: boolean; + isolated?: boolean; + config: Record; +} + +export interface SensorManifest { + version: 1; + sensors: SensorEntry[]; +} + +export interface DispatcherDispatchInput { + sensorId: string; + skillId: string; + signal: W2ASignal; + sessionId?: string; +} + +export interface Dispatcher { + dispatch(input: DispatcherDispatchInput): Promise; +} + +export interface EmbeddedDispatcherOptions { + api: OpenClawPluginApi; + openclawConfigRef: { current: OpenClawConfig }; + pluginConfig: RequiredWorld2AgentPluginConfig; + paths: World2AgentPaths; +} + +export interface HttpIngestEnvelope { + sensor_id: string; + skill_id: string; + signal: W2ASignal; +} + +export interface HttpDispatcherOptions { + embeddedDispatcher: Dispatcher; + hmacSecret: string; + dedupTtlMs: number; +} + +export interface RuntimeHandle { + sensorId: string; + pkg: string; + skillId: string; + isolated: boolean; + configHash: string; + startedAt: number; + cleanup: CleanupFn; + flush?: () => Promise; +} + +export interface ApplyResult { + started: string[]; + restarted: string[]; + stopped: string[]; + failed: Array<{ sensor_id: string; error: string }>; +} + +export interface RequiredWorld2AgentPluginConfig { + sensorsManifestPath?: string; + stateDir?: string; + sessionDir?: string; + workspaceDir?: string; + ingestUrl?: string; + defaultAgentId: string; + provider?: string; + model?: string; + requestTimeoutMs: number; + ingestHmacSecretFile?: string; + ingestDedupTtlMs: number; +} + +export interface IsolatedRunnerHandle { + sensorId: string; + pkg: string; + skillId: string; + isolated: true; + configHash: string; + startedAt: number; + cleanup: CleanupFn; +} + +export type ParsedPluginConfig = OpenClawPluginConfig; diff --git a/openclaw-plugin/test/context-injection.test.ts b/openclaw-plugin/test/context-injection.test.ts new file mode 100644 index 0000000..1df44f1 --- /dev/null +++ b/openclaw-plugin/test/context-injection.test.ts @@ -0,0 +1,52 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createWorld2AgentPlugin } from "../src/plugin.js"; + +const ORIGINAL_W2A_HOME = process.env.W2A_HOME; +const ORIGINAL_OPENCLAW_HOME = process.env.OPENCLAW_HOME; + +describe("contextInjection startup check", () => { + afterEach(() => { + process.env.W2A_HOME = ORIGINAL_W2A_HOME; + process.env.OPENCLAW_HOME = ORIGINAL_OPENCLAW_HOME; + }); + + it("fails register() synchronously when agents.defaults.contextInjection is not continuation-skip", async () => { + const root = await mkdtemp(join(tmpdir(), "w2a-openclaw-register-")); + process.env.W2A_HOME = join(root, "w2a"); + process.env.OPENCLAW_HOME = join(root, "openclaw"); + + const plugin = createWorld2AgentPlugin(); + expect(() => + plugin.register({ + config: { + agents: { + defaults: { + contextInjection: "always", + }, + }, + }, + pluginConfig: {}, + }), + ).toThrow( + "OpenClaw config field `agents.defaults.contextInjection` must be set to \"continuation-skip\"", + ); + }); + + it("register() returns synchronously (not a promise) — OpenClaw drops async registers", async () => { + const root = await mkdtemp(join(tmpdir(), "w2a-openclaw-sync-")); + process.env.W2A_HOME = join(root, "w2a"); + process.env.OPENCLAW_HOME = join(root, "openclaw"); + + const plugin = createWorld2AgentPlugin(); + const result = plugin.register({ + registrationMode: "cli-metadata", + pluginConfig: {}, + registerCli: () => {}, + }); + expect(result).toBeUndefined(); + }); +}); + diff --git a/openclaw-plugin/test/dispatch.test.ts b/openclaw-plugin/test/dispatch.test.ts new file mode 100644 index 0000000..5cfb446 --- /dev/null +++ b/openclaw-plugin/test/dispatch.test.ts @@ -0,0 +1,146 @@ +import { createHmac } from "node:crypto"; +import { PassThrough } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; +import { EmbeddedDispatcher, HttpDispatcher } from "../src/dispatch.js"; +import type { EmbeddedAgentRunRequest } from "../src/openclaw/plugin-sdk/types.js"; +import type { HttpIngestEnvelope, World2AgentPaths } from "../src/types.js"; + +const TEST_SIGNAL = { + signal_id: "sig-1", + schema_version: "w2a/0.1" as const, + emitted_at: Date.now(), + source: { + sensor_id: "@world2agent/sensor-fake-tick", + sensor_version: "0.0.1", + source_type: "fake", + user_identity: "unknown", + package: "@world2agent/sensor-fake-tick", + }, + event: { + type: "news.item.created", + occurred_at: Date.now(), + summary: "A fake tick signal fired for dispatcher tests.", + }, +}; + +describe("EmbeddedDispatcher", () => { + it("serializes by sensor session and renders the prompt-prefix fallback", async () => { + const calls: EmbeddedAgentRunRequest[] = []; + const dispatcher = new EmbeddedDispatcher({ + api: { + runtime: { + agent: { + runEmbeddedAgent: vi.fn(async (request: EmbeddedAgentRunRequest) => { + calls.push(request); + return { ok: true }; + }), + }, + }, + }, + openclawConfigRef: { + current: { + agents: { + defaults: { + contextInjection: "continuation-skip", + }, + list: [], + }, + }, + }, + pluginConfig: { + defaultAgentId: "world2agent", + requestTimeoutMs: 12_345, + ingestDedupTtlMs: 3_600_000, + }, + paths: makePaths("/tmp/w2a-openclaw-dispatch"), + }); + + await dispatcher.dispatch({ + sensorId: "fake-tick", + skillId: "world2agent-sensor-fake-tick", + signal: TEST_SIGNAL, + }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.sessionId).toBe("w2a-fake-tick"); + expect(calls[0]?.sessionKey).toBe("w2a:fake-tick"); + expect(calls[0]?.timeoutMs).toBe(12_345); + expect(calls[0]?.prompt.startsWith("Use skill: world2agent-sensor-fake-tick")).toBe( + true, + ); + }); +}); + +describe("HttpDispatcher", () => { + it("validates HMAC and dedups X-Request-ID", async () => { + const dispatch = vi.fn(async () => ({ ok: true })); + const http = new HttpDispatcher({ + embeddedDispatcher: { dispatch }, + hmacSecret: "secret", + dedupTtlMs: 60_000, + }); + + const payload: HttpIngestEnvelope = { + sensor_id: "fake-tick", + skill_id: "world2agent-sensor-fake-tick", + signal: TEST_SIGNAL, + }; + const body = JSON.stringify(payload); + const signature = createHmac("sha256", "secret").update(body).digest("hex"); + + const first = await invokeRoute(http, body, "req-1", signature); + const second = await invokeRoute(http, body, "req-1", signature); + + expect(first.statusCode).toBe(202); + expect(second.statusCode).toBe(202); + expect(second.body).toContain("\"deduped\": true"); + expect(dispatch).toHaveBeenCalledTimes(1); + }); +}); + +async function invokeRoute( + http: HttpDispatcher, + body: string, + requestId: string, + signature: string, +): Promise<{ statusCode: number; body: string }> { + const req = new PassThrough() as PassThrough & { + headers: Record; + method: string; + }; + req.headers = { + "x-request-id": requestId, + "x-webhook-signature": signature, + }; + req.method = "POST"; + req.end(body); + + let responseBody = ""; + const res = { + statusCode: 200, + setHeader: vi.fn(), + end: vi.fn((value?: string) => { + responseBody = value ?? ""; + }), + }; + + await http.handle(req as any, res as any); + + return { + statusCode: res.statusCode, + body: responseBody, + }; +} + +function makePaths(root: string): World2AgentPaths { + return { + baseDir: root, + manifestFile: `${root}/sensors.json`, + stateDir: `${root}/state`, + sessionDir: `${root}/sessions`, + openclawHome: `${root}/.openclaw`, + openclawSkillsDir: `${root}/.openclaw/skills`, + ingestHmacSecretFile: `${root}/.secret`, + }; +} + diff --git a/openclaw-plugin/test/manifest.test.ts b/openclaw-plugin/test/manifest.test.ts new file mode 100644 index 0000000..52f9ed1 --- /dev/null +++ b/openclaw-plugin/test/manifest.test.ts @@ -0,0 +1,89 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + readManifest, + removeSensorEntry, + upsertSensorEntry, + writeManifest, +} from "../src/manifest.js"; +import type { World2AgentPaths } from "../src/types.js"; + +describe("manifest helpers", () => { + it("writes and reads a normalized manifest", async () => { + const root = await mkdtemp(join(tmpdir(), "w2a-openclaw-manifest-")); + const paths = makePaths(root); + + await writeManifest(paths, { + version: 1, + sensors: [ + { + sensor_id: "hackernews", + pkg: "@world2agent/sensor-hackernews", + skill_id: "ignored-on-write", + enabled: true, + isolated: true, + config: { interval_ms: 30_000 }, + }, + ], + }); + + const manifest = await readManifest(paths); + expect(manifest).toEqual({ + version: 1, + sensors: [ + { + sensor_id: "hackernews", + pkg: "@world2agent/sensor-hackernews", + skill_id: "world2agent-sensor-hackernews", + enabled: true, + isolated: true, + config: { interval_ms: 30_000 }, + }, + ], + }); + }); + + it("upserts and removes entries by sensor id", () => { + const initial = { + version: 1 as const, + sensors: [], + }; + + const afterInsert = upsertSensorEntry(initial, { + sensor_id: "news", + pkg: "@world2agent/sensor-hackernews", + skill_id: "world2agent-sensor-hackernews", + enabled: true, + config: {}, + }); + const afterUpdate = upsertSensorEntry(afterInsert, { + sensor_id: "news", + pkg: "@world2agent/sensor-hackernews", + skill_id: "world2agent-sensor-hackernews", + enabled: true, + config: { interval_ms: 60_000 }, + }); + + expect(afterUpdate.sensors).toHaveLength(1); + expect(afterUpdate.sensors[0]?.config).toEqual({ interval_ms: 60_000 }); + + const removed = removeSensorEntry(afterUpdate, "news"); + expect(removed.removed?.sensor_id).toBe("news"); + expect(removed.manifest.sensors).toEqual([]); + }); +}); + +function makePaths(root: string): World2AgentPaths { + return { + baseDir: root, + manifestFile: join(root, "sensors.json"), + stateDir: join(root, "state"), + sessionDir: join(root, "sessions"), + openclawHome: join(root, ".openclaw"), + openclawSkillsDir: join(root, ".openclaw", "skills"), + ingestHmacSecretFile: join(root, ".secret"), + }; +} + diff --git a/openclaw-plugin/test/supervisor-shared.test.ts b/openclaw-plugin/test/supervisor-shared.test.ts new file mode 100644 index 0000000..a09adb4 --- /dev/null +++ b/openclaw-plugin/test/supervisor-shared.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + buildIsolatedRunnerEnv, + hashConfig, + readJsonFromStdin, + shouldRestartIsolatedHandle, +} from "../src/supervisor/shared.js"; + +describe("supervisor shared boundary", () => { + it("builds the isolated runner env expected by the reused runner contract", () => { + const env = buildIsolatedRunnerEnv({ + pkg: "@world2agent/sensor-fake-tick", + sensorId: "fake-tick", + skillId: "world2agent-sensor-fake-tick", + ingestUrl: "http://127.0.0.1:3333/w2a/ingest", + hmacSecret: "secret", + statePath: "/tmp/fake-tick.json", + }); + + expect(env.W2A_PACKAGE).toBe("@world2agent/sensor-fake-tick"); + expect(env.W2A_SENSOR_ID).toBe("fake-tick"); + expect(env.W2A_SKILL_ID).toBe("world2agent-sensor-fake-tick"); + expect(env.W2A_INGEST_URL).toBe("http://127.0.0.1:3333/w2a/ingest"); + }); + + it("detects whether an isolated handle needs restart", () => { + const same = shouldRestartIsolatedHandle( + { + sensorId: "fake-tick", + pkg: "@world2agent/sensor-fake-tick", + skillId: "world2agent-sensor-fake-tick", + webhookUrl: "http://127.0.0.1:3333/w2a/ingest", + configHash: hashConfig({ + interval_ms: 60_000, + }), + }, + { + sensor_id: "fake-tick", + pkg: "@world2agent/sensor-fake-tick", + skill_id: "world2agent-sensor-fake-tick", + enabled: true, + isolated: true, + config: { + interval_ms: 60_000, + }, + }, + "http://127.0.0.1:3333/w2a/ingest", + ); + + const changed = shouldRestartIsolatedHandle( + { + sensorId: "fake-tick", + pkg: "@world2agent/sensor-fake-tick", + skillId: "world2agent-sensor-fake-tick", + webhookUrl: "http://127.0.0.1:3333/w2a/ingest", + configHash: "old", + }, + { + sensor_id: "fake-tick", + pkg: "@world2agent/sensor-fake-tick", + skill_id: "world2agent-sensor-fake-tick", + enabled: true, + isolated: true, + config: { + interval_ms: 60_000, + }, + }, + "http://127.0.0.1:3333/w2a/ingest", + ); + + expect(same).toBe(false); + expect(changed).toBe(true); + }); + + it("parses config JSON from stdin-compatible streams", async () => { + async function* chunks() { + yield '{"interval_ms":60000}'; + } + + await expect(readJsonFromStdin(chunks())).resolves.toEqual({ + interval_ms: 60_000, + }); + }); +}); diff --git a/openclaw-plugin/tsconfig.json b/openclaw-plugin/tsconfig.json new file mode 100644 index 0000000..3205970 --- /dev/null +++ b/openclaw-plugin/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "declaration": true, + "outDir": "dist", + "types": [ + "node" + ] + }, + "include": [ + "src/**/*" + ] +} + diff --git a/openclaw-plugin/vitest.config.ts b/openclaw-plugin/vitest.config.ts new file mode 100644 index 0000000..2233cef --- /dev/null +++ b/openclaw-plugin/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["test/**/*.test.ts"], + }, +}); + From cc85bd74af6474c98e57ea314fd1d83a0bad8031 Mon Sep 17 00:00:00 2001 From: Nova-machinepulse Date: Wed, 29 Apr 2026 16:08:13 +0800 Subject: [PATCH 2/9] feat(openclaw-plugin): improve sensor onboarding and dispatching --- openclaw-plugin/README.md | 155 +++- openclaw-plugin/openclaw.plugin.json | 4 +- openclaw-plugin/pnpm-lock.yaml | 793 ++++++++++++++++++ .../skills/world2agent-manage/SKILL.md | 206 ++++- openclaw-plugin/src/cli.ts | 53 +- openclaw-plugin/src/config.ts | 32 +- openclaw-plugin/src/dispatch.ts | 222 ++++- openclaw-plugin/src/install.ts | 53 +- openclaw-plugin/src/manifest.ts | 2 +- .../src/openclaw/plugin-sdk/types.ts | 82 +- openclaw-plugin/src/runtime.ts | 22 +- openclaw-plugin/test/dispatch.test.ts | 22 +- openclaw-plugin/test/manifest.test.ts | 30 +- 13 files changed, 1552 insertions(+), 124 deletions(-) create mode 100644 openclaw-plugin/pnpm-lock.yaml diff --git a/openclaw-plugin/README.md b/openclaw-plugin/README.md index 266168d..7fb578d 100644 --- a/openclaw-plugin/README.md +++ b/openclaw-plugin/README.md @@ -1,42 +1,141 @@ # @world2agent/openclaw-plugin -Native OpenClaw plugin for running World2Agent sensors and dispatching their signals into embedded OpenClaw agent turns. +Native OpenClaw plugin for running World2Agent sensors and dispatching their signals into OpenClaw agent turns as **system events** (not user messages). -The default path is in-process: enabled sensors are imported directly inside the plugin process and each signal is sent to `api.runtime.agent.runEmbeddedAgent(...)`. `isolated: true` is opt-in and reuses the Hermes bridge runner/supervisor patterns for subprocess execution plus plugin-local HTTP ingest. +The default path is in-process: enabled sensors are imported directly inside the plugin process, and each emitted signal is enqueued as a system event for a dedicated agent. OpenClaw drains queued system events at the start of the next agent turn and prepends them to the prompt as `System:` lines — matching the semantics `claude-code-channel` uses with MCP `notifications/claude/channel`. + +`isolated: true` is opt-in and reuses the Hermes bridge runner/supervisor patterns for subprocess execution plus plugin-local HTTP ingest. ## Install -1. Set the required OpenClaw agent config first: +> ⚠️ OpenClaw config is **JSON**, not YAML. All steps below assume `~/.openclaw/openclaw.json`. + +### 1. Set the contextInjection prerequisite + +The plugin refuses to start unless `agents.defaults.contextInjection` is exactly `"continuation-skip"` (otherwise OpenClaw's default `"always"` re-injects bootstrap on every signal and silently turns sensors into a token sink). + +```bash +# Edit in place (no `sponge` dependency — works on stock macOS / Linux) +jq '.agents.defaults.contextInjection = "continuation-skip"' \ + ~/.openclaw/openclaw.json > /tmp/openclaw.json.tmp && \ + mv /tmp/openclaw.json.tmp ~/.openclaw/openclaw.json + +# Verify +jq '.agents.defaults.contextInjection' ~/.openclaw/openclaw.json +# → "continuation-skip" +``` + +### 2. Install the plugin + +> ⚠️ The plugin uses `child_process` for sensor subprocess management (required for `isolated: true` mode and for npm install/uninstall of sensor packages). OpenClaw's built-in security scan **blocks** plugins with `child_process` by default. The output will show a wall of `WARNING` lines listing every `child_process` site — **that is expected**. The actual success markers are at the bottom: `Linked plugin path` (or `Installed plugin`) and `Restart the gateway to load plugins`. + +#### Standard (from npm) + +```bash +openclaw plugins install @world2agent/openclaw-plugin --dangerously-force-unsafe-install +openclaw gateway restart +``` + +#### Contributors / pre-release testing (from local source) + +If you're hacking on this plugin and haven't published to npm yet: + +```bash +cd world2agent-plugins/openclaw-plugin +pnpm install +pnpm build + +# Use an ABSOLUTE path. OpenClaw's `plugins install` also accepts hook packs +# (a different concept) — a relative or `~` path can be misclassified and +# yield a confusing "HOOK.md missing in ..." error. Absolute path tells +# OpenClaw "this is the plugin you just built." +openclaw plugins install -l --dangerously-force-unsafe-install \ + "$(pwd)" +openclaw gateway restart +``` + +Verify it loaded: + +```bash +openclaw plugins list | grep world2agent +# → │ World2Agent │ world2agent │ openclaw │ enabled │ ... │ 0.0.0-dev │ +openclaw world2agent --help +# → Commands: reload, sensor +``` + +### 4. Subscribe to your first source — by talking to OpenClaw + +> ℹ️ By default W2A signals lane through your **existing `main` agent** but on a different sessionKey (one per sensor), so they don't pollute your normal chat session. If you'd rather route them to a dedicated agent for full isolation, set `defaultAgentId: "world2agent"` (or any other id) in this plugin's config and `openclaw agents add ` first. - ```yaml - agents: - defaults: - contextInjection: continuation-skip - ``` +The preferred path is conversational. Just tell main agent what you want: -2. Install dependencies and build this package: +```bash +openclaw chat --agent main +``` - ```bash - cd world2agent-plugins/openclaw-plugin - pnpm install - pnpm build - ``` +``` +> 帮我订阅 Hacker News,我关心 AI 和安全话题 +``` -3. Add the plugin package to your OpenClaw plugin search/install path and enable `@world2agent/openclaw-plugin`. +The plugin ships a `world2agent-manage` skill that activates on this kind of intent. Main agent will: -4. Use the registered CLI: +1. Read the sensor's `SETUP.md` (e.g. `node_modules/@world2agent/sensor-hackernews/SETUP.md`) +2. Ask you 1–3 questions defined in that file (poll thresholds, your topics of interest, reply depth) +3. Fill the SKILL.md template with your answers and write it to `~/.openclaw/skills/world2agent-sensor-hackernews/SKILL.md` +4. Run `openclaw world2agent sensor add ... --skip-generate-skill` to register +5. Tell you when the first signal will arrive - ```bash - openclaw world2agent sensor list - openclaw world2agent sensor add @world2agent/sensor-hackernews --config-file ./hackernews.json - ``` +This personalized SKILL.md is what makes the agent reply meaningfully to relevant signals (instead of skipping every signal silently because it has no anchor for "what's relevant to this user"). + +#### CLI fallback (power users / scripting) + +If you want to script the install or skip the Q&A, you can still call the CLI directly: + +```bash +openclaw world2agent sensor add @world2agent/sensor-hackernews \ + --config-json '{"top_n":10,"min_score":50,"min_comments":0,"interval_seconds":60}' +``` + +Without `--skip-generate-skill`, the CLI will write a **generic** SKILL.md to `~/.openclaw/skills/world2agent-sensor-hackernews/SKILL.md` (only if no SKILL.md is already there). The generic skill makes the agent reply briefly to every signal — fine for testing, noisy for daily use. Edit that file later to add filtering rules. + +> ⚠️ Plugin config is cached at register time — newly-added sensors are visible to the running plugin only after a reload: + +```bash +openclaw world2agent reload +# falls back to `openclaw gateway restart` if reload times out +``` + +Within ~60 seconds the sensor will start polling. Each emitted signal triggers an agent turn under sessionKey `agent:main:w2a-`, with the signal framed as a `# System Event` block. + +## Where to view signal-driven agent runs + +Each sensor gets a stable session id `w2a-`, scoped to your default agent (`main` unless you overrode `defaultAgentId`). Signals are injected via OpenClaw's system-event queue + heartbeat — **the W2A signal arrives as a `System:` block in the agent turn, not as a user message**. You can view the resulting conversation in three ways: + +```bash +# CLI — list W2A sessions on the main agent +openclaw sessions --agent main --active 60 +# (or `--agent world2agent` if you set defaultAgentId to a dedicated agent) + +# Dashboard — open the OpenClaw control UI +open http://127.0.0.1:18789/ + +# Direct file access (for debugging) +ls ~/.openclaw/agents/main/sessions/ +# w2a-hackernews.jsonl ← session metadata +# w2a-hackernews.trajectory.jsonl ← full LLM tool-call trajectory +# sessions.json ← OpenClaw session index (includes +# `agent:main:w2a-` lanes +# alongside `agent:main:main` chat lane) +``` + +Your normal chat with `main` agent (sessionKey `agent:main:main`) is **untouched** — W2A signals only show up under `agent:main:w2a-` lanes. ## Scope - Reads and writes the W2A sensor manifest at `~/.world2agent/sensors.json` by default. - Runs sensors in-process unless a sensor entry sets `isolated: true`. - Reuses the Hermes runner/supervisor patterns instead of inventing a second isolation protocol. -- Uses a stable per-sensor embedded-agent session id: `w2a:`. +- Uses a stable per-sensor session id: `w2a-` (and session key `agent::w2a-`). - Requires plugin config `ingestUrl` only when `isolated: true` sensors are used. ## ContextInjection Prerequisite @@ -45,10 +144,16 @@ This plugin refuses to start unless `agents.defaults.contextInjection` is exactl That check also runs before `openclaw world2agent sensor add`. There is no warning mode, no fallback mode, and no override flag. The design requires a hard failure because OpenClaw's default `"always"` setting would re-inject bootstrap on every sensor signal and silently turn high-frequency sensors into a token sink. -## Relation To `hermes-sensor-bridge` +## Relation to `hermes-sensor-bridge` + +`hermes-sensor-bridge` solved the same World2Agent runtime problem for Hermes with webhook subscriptions plus supervised subprocesses. This package keeps the same manifest shape and reuses the runner/supervisor mechanics for `isolated: true`, but the primary OpenClaw path is simpler: native plugin registration plus `enqueueSystemEvent(...)` + `runEmbeddedAgent(...)`. + +## Troubleshooting + +**Plugin install blocked by safety scanner**: that's the security warning about `child_process`. Use `--dangerously-force-unsafe-install` (see step 3). -`hermes-sensor-bridge` solved the same World2Agent runtime problem for Hermes with webhook subscriptions plus supervised subprocesses. This package keeps the same manifest shape and reuses the runner/supervisor mechanics for `isolated: true`, but the primary OpenClaw path is simpler: native plugin registration plus `runEmbeddedAgent(...)`. +**`openclaw world2agent --help` says "unknown command"**: gateway hasn't reloaded the plugin yet. Run `openclaw gateway restart`. -## Known M0 Spike +**Sensors run but `openclaw sessions --agent world2agent` is empty**: you skipped step 4 (`openclaw agents add world2agent`) or step 5's `openclaw world2agent reload`. Each sensor's `dispatch failed` will be logged in `/tmp/openclaw/openclaw-*.log` — grep for the sensor id. -`api.runtime.agent.runEmbeddedAgent(...)` from a third-party external plugin remains a live-install verification point. This package guards it defensively and throws a clear error if the runtime helper is absent, but a real OpenClaw install still has to confirm the end-to-end external-plugin path. +**Wizards or interactive commands hang on the same terminal as the gateway**: sensor logs go to a namespaced logger, but very early gateway boot output still goes to stdout. Run interactive commands (`openclaw agents add ...`) from a terminal that isn't tailing gateway logs. diff --git a/openclaw-plugin/openclaw.plugin.json b/openclaw-plugin/openclaw.plugin.json index 550e803..6b0054b 100644 --- a/openclaw-plugin/openclaw.plugin.json +++ b/openclaw-plugin/openclaw.plugin.json @@ -34,8 +34,8 @@ }, "defaultAgentId": { "type": "string", - "default": "world2agent", - "description": "Dedicated OpenClaw agent id whose skills allowlist should receive W2A skills." + "default": "main", + "description": "OpenClaw agent id W2A signals lane through. Defaults to 'main' so signals share the user's existing main agent (different sessionKey per sensor — no cross-contamination of the user's chat session). Set to a dedicated agent id (e.g. 'world2agent') if you want full isolation; that agent must exist via `openclaw agents add `." }, "provider": { "type": "string", diff --git a/openclaw-plugin/pnpm-lock.yaml b/openclaw-plugin/pnpm-lock.yaml new file mode 100644 index 0000000..ab172bb --- /dev/null +++ b/openclaw-plugin/pnpm-lock.yaml @@ -0,0 +1,793 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@world2agent/sdk': + specifier: file:../../world2agent-typescript-sdk + version: file:../../world2agent-typescript-sdk(zod@3.25.76) + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^25.5.0 + version: 25.6.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.1.5 + version: 4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)) + +packages: + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.17': + resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + + '@world2agent/sdk@file:../../world2agent-typescript-sdk': + resolution: {directory: ../../world2agent-typescript-sdk, type: directory} + engines: {node: '>=20'} + peerDependencies: + zod: ^3.25.0 + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.12: + resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.0.0-rc.17: + resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.1: + resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + + vite@8.0.10: + resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@oxc-project/types@0.127.0': {} + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.17': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/node@25.6.0': + dependencies: + undici-types: 7.19.2 + + '@vitest/expect@4.1.5': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.10(@types/node@25.6.0) + + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.5': + dependencies: + '@vitest/utils': 4.1.5 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.5': {} + + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@world2agent/sdk@file:../../world2agent-typescript-sdk(zod@3.25.76)': + dependencies: + zod: 3.25.76 + + assertion-error@2.0.1: {} + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + detect-libc@2.1.2: {} + + es-module-lexer@2.1.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + nanoid@3.3.11: {} + + obug@2.1.1: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.12: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.0.0-rc.17: + dependencies: + '@oxc-project/types': 0.127.0 + '@rolldown/pluginutils': 1.0.0-rc.17 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-x64': 1.0.0-rc.17 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.1.1: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tslib@2.8.1: + optional: true + + typescript@5.9.3: {} + + undici-types@7.19.2: {} + + vite@8.0.10(@types/node@25.6.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.12 + rolldown: 1.0.0-rc.17 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.6.0 + fsevents: 2.3.3 + + vitest@4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.10(@types/node@25.6.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.6.0 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + zod@3.25.76: {} diff --git a/openclaw-plugin/skills/world2agent-manage/SKILL.md b/openclaw-plugin/skills/world2agent-manage/SKILL.md index 69ca8a7..15b660d 100644 --- a/openclaw-plugin/skills/world2agent-manage/SKILL.md +++ b/openclaw-plugin/skills/world2agent-manage/SKILL.md @@ -6,74 +6,208 @@ user-invocable: false # World2Agent Sensor Management -You manage the user's World2Agent sensors on this OpenClaw machine. - -All mutations go through the `openclaw world2agent` CLI. The shell scripts in -`scripts/` are thin wrappers around those commands. +You manage the user's World2Agent sensors on this OpenClaw machine. Sensors +are long-running probes that watch the outside world (news, repos, markets, +pages, calendars) and dispatch structured signals back into the agent as +`# System Event` turns. ## Prerequisite -Before adding sensors, OpenClaw must be configured with: +OpenClaw must have `agents.defaults.contextInjection` set to +`"continuation-skip"` in `~/.openclaw/openclaw.json` (it's JSON, not YAML). +The plugin refuses to start otherwise. Verify before doing anything else: -```yaml -agents: - defaults: - contextInjection: continuation-skip +```bash +jq '.agents.defaults.contextInjection' ~/.openclaw/openclaw.json ``` -If that field is not set exactly, `openclaw world2agent sensor add` will fail on -purpose. Do not try to work around it. +If the value is anything other than `"continuation-skip"`, ask the user for +permission to fix it, then run: -## List sensors +```bash +jq '.agents.defaults.contextInjection = "continuation-skip"' \ + ~/.openclaw/openclaw.json > /tmp/oc.tmp && \ + mv /tmp/oc.tmp ~/.openclaw/openclaw.json +``` + +## Install a sensor (the conversational flow — preferred) + +When the user expresses interest in an outside-world source ("subscribe me to +Hacker News", "watch this GitHub repo", "ping me on calendar events"), drive +the install end-to-end through dialogue. Do NOT just shell out to the CLI +with default config — the auto-generated handler is generic and the agent +will not reply meaningfully to signals without the user's preferences baked +in. + +### Step 1 — Identify the package + +Map the user's phrase to an npm package name. Common ones: + +- "Hacker News" / "HN" → `@world2agent/sensor-hackernews` +- "GitHub releases" / "watch repo" → `@world2agent/sensor-github` +- generic feed → ask the user for the npm package name + +If unsure, look up what's available: + +- **Sensor hub (canonical catalog)**: — browse + every published sensor with its description, configuration parameters, + and the events it emits. Use the WebFetch tool on this URL to enumerate + available sensors when the user asks "what can I subscribe to?". +- **npm discovery**: `npm search @world2agent/sensor- --json | jq '.[] | {name, description}'` +- **Already installed locally**: read `~/.world2agent/sensors.json`. + +If the user describes something that doesn't match any sensor on the hub +(e.g. "subscribe to my Notion tasks" with no Notion sensor), say so plainly +and offer two paths: pick the closest existing sensor, or write a new +sensor following the W2A SDK template (linked from the hub). -Run: +Confirm the package name with the user before continuing. + +### Step 2 — Read the sensor's SETUP.md + +Every sensor package ships a `SETUP.md` that defines: +- the configuration parameters the sensor takes (with defaults) +- the questions YOU must ask the user, one at a time, in their language +- the SKILL.md template to fill from the user's answers + +To locate SETUP.md you first need the plugin's install directory. Use +`openclaw plugins list --json` (the canonical way — works in both link mode +and copy mode): ```bash -bash "$W2A_PLUGIN_HOME/skills/world2agent-manage/scripts/list.sh" +PLUGIN_DIR=$(openclaw plugins list --json | \ + jq -r '.plugins[] | select(.id == "world2agent") | .rootDir') +echo "$PLUGIN_DIR" +# → e.g. /Users//Documents/.../openclaw-plugin ``` -## Install a sensor +Then check whether the sensor package is already installed there: + +```bash +SETUP="$PLUGIN_DIR/node_modules//SETUP.md" +ls "$SETUP" 2>/dev/null || echo "not installed yet" +``` -1. Confirm the npm package name with the user. -2. Inspect the sensor package's `SETUP.md` to determine the config fields it needs. -3. Write a temporary JSON file containing the sensor config object only. -4. Run: +If it isn't installed, install it in-place (no manifest mutation, just +populates `node_modules/`): ```bash -bash "$W2A_PLUGIN_HOME/skills/world2agent-manage/scripts/add.sh" --config-file +( cd "$PLUGIN_DIR" && npm install --no-save ) ``` +Then read SETUP.md with the Read tool, passing the absolute path +`$PLUGIN_DIR/node_modules//SETUP.md`. + +(Reading SETUP.md upfront — before `sensor add` — is preferred so the user +can answer questions before any state mutation. The actual sensor +registration happens in step 6.) + +### Step 3 — Run the Q&A, one question at a time + +SETUP.md lists 1-3 questions under "Questions to Ask". Ask them ONE AT A TIME, +in the user's language, waiting for each answer before continuing. Do NOT +batch-ask. Do NOT invent your own questions. Do NOT skip questions even if +the user seems impatient — every placeholder in the SKILL.md template +corresponds to one of these answers. + +### Step 4 — Fill the SKILL.md template + +SETUP.md provides a template in its "Output" section, with placeholders like +`[USER_TOPICS]`, `[USER_NORMAL_STYLE]`, `[USER_DEEP_DIVE_THRESHOLD]`. Replace +each placeholder with the user's answer (or the default the user accepted). +Show the filled SKILL.md to the user for confirmation before writing. + +### Step 5 — Write the personalized SKILL.md + +Write to `~/.openclaw/skills//SKILL.md` (NOT to Claude Code's +`~/.claude/skills/...` — that's the channel-side path, irrelevant here). +Compute `` from the package name: strip leading `@`, replace `/` +with `-`. Example: `@world2agent/sensor-hackernews` → +`world2agent-sensor-hackernews`. + +```bash +mkdir -p ~/.openclaw/skills/ +# Then write SKILL.md via the Write tool, with the filled template. +``` + +### Step 6 — Register the sensor with the plugin + +Build the sensor's config JSON object (just the `config` block from SETUP.md's +"Configuration Parameters" table, with the user's answers). Then call: + +```bash +openclaw world2agent sensor add \ + --config-json '' \ + --skip-generate-skill +``` + +The `--skip-generate-skill` flag is critical: it tells the CLI to keep the +personalized SKILL.md you just wrote in step 5. Without it, the CLI's +fallback would overwrite your work with a generic template. + Optional flags: +- `--sensor-id ` for a non-default instance id (only if the user wants + multiple instances of the same sensor) +- `--isolated` to run the sensor out-of-process (for unstable third-party + sensors) -- `--sensor-id ` if the user wants a non-default instance id. -- `--isolated` if the sensor should run out-of-process. +### Step 7 — Confirm and reload -Never invent credentials or secrets. Ask the user when the config requires them. +If the CLI's `reload` field returns `ok: true`, the sensor is polling. If +not, ask the user to run `openclaw gateway restart` (the plugin process +caches its config at register time). -## Remove a sensor +Tell the user: +- which sensor id was created +- where the personalized SKILL.md lives +- when to expect the first signal (sensor's poll interval) -Run: +## List sensors + +```bash +openclaw world2agent sensor list +``` + +Returns the manifest plus the current `contextInjection` value. + +## Remove a sensor ```bash -bash "$W2A_PLUGIN_HOME/skills/world2agent-manage/scripts/remove.sh" +openclaw world2agent sensor remove ``` -Pass `--purge` only if the user explicitly wants the generated OpenClaw skill -directory removed too. +Add `--purge` only if the user wants the generated handler skill directory +deleted too (this is destructive — confirm first). -## Reload sensors +## Reload after manual edits -Run: +If the user hand-edited `~/.world2agent/sensors.json` or a personalized +SKILL.md, run: ```bash -bash "$W2A_PLUGIN_HOME/skills/world2agent-manage/scripts/reload.sh" +openclaw world2agent reload ``` -## Output style +If reload fails, fall back to `openclaw gateway restart`. -After each action, summarize: +## Common mistakes to avoid -- which sensor ids were affected -- whether the reload succeeded -- any warnings or errors returned by the CLI +- Do NOT skip the SETUP.md Q&A flow. Without `[USER_TOPICS]` / + `[USER_NORMAL_STYLE]` (or whatever the SETUP.md template defines) filled in, + the agent has no anchor for "what's relevant" and will skip most signals + silently — burning tokens on `NO_REPLY` turns. +- Do NOT write SKILL.md to `~/.claude/skills/...`. That's the channel-side + (Claude Code) path. OpenClaw reads from `~/.openclaw/skills/...`. +- Do NOT invent credentials. If SETUP.md asks for an API key, ask the user. +- Do NOT call `sensor add` before writing SKILL.md if you intend to + personalize. The CLI's fallback will skip when SKILL.md exists, but the + cleaner ordering is: write SKILL.md first, then `sensor add + --skip-generate-skill`. +## Output style + +After each action, summarize concisely: +- which sensor ids were affected +- whether reload succeeded (or instruct user to restart gateway) +- where the personalized SKILL.md lives, so the user knows what to edit + later if their preferences change diff --git a/openclaw-plugin/src/cli.ts b/openclaw-plugin/src/cli.ts index 32705d6..451bac3 100644 --- a/openclaw-plugin/src/cli.ts +++ b/openclaw-plugin/src/cli.ts @@ -43,8 +43,19 @@ export function registerWorld2AgentCli(services: World2AgentCliServices): void { .command("add ") .description("Install and configure a sensor") .option("--sensor-id ", "Override the default sensor id") - .option("--config-file ", "Path to the sensor config JSON file") + .option( + "--config-file ", + "Path to a sensor config JSON file", + ) + .option( + "--config-json ", + "Inline JSON config string (alternative to --config-file)", + ) .option("--isolated", "Run this sensor out-of-process") + .option( + "--skip-generate-skill", + "Do not auto-generate a fallback SKILL.md. Use this when the calling agent has already written a personalized handler skill via the SETUP.md Q&A flow.", + ) .action(async (pkg: string, options: Record) => { printJson(await runAddCommand(services, pkg, options)); }); @@ -100,10 +111,21 @@ async function runAddCommand( const installed = await ensurePackageInstalled(pkg); const sensorId = optionString(options, "sensorId") ?? defaultSensorId(pkg); const configFile = optionString(options, "configFile"); + const configJson = optionString(options, "configJson"); const isolated = optionBoolean(options, "isolated"); + const skipGenerateSkill = optionBoolean(options, "skipGenerateSkill"); const skillId = packageToSkillId(pkg); - const sensorConfig = await loadConfigFile(configFile, installed); - await writeGeneratedSkill(services.paths, pkg, installed); + const sensorConfig = await loadConfigFile(configFile, configJson, installed); + + // The agent-driven path (world2agent-manage skill running SETUP.md Q&A) + // writes a personalized SKILL.md before invoking this command and passes + // --skip-generate-skill. The fallback path (direct CLI use) lets the + // helper write a generic SKILL.md, but only when the file doesn't exist. + let skillGenerated: { written: boolean } = { written: false }; + if (!skipGenerateSkill) { + const result = await writeGeneratedSkill(services.paths, pkg, installed); + skillGenerated = { written: result.written }; + } const manifest = await readManifest(services.paths); const entry: SensorEntry = { @@ -129,6 +151,8 @@ async function runAddCommand( sensor_id: sensorId, skill_id: skillId, isolated, + skill_generated: skillGenerated.written, + skill_path: join(services.paths.openclawSkillsDir, skillId, "SKILL.md"), allowlist, reload, }; @@ -196,8 +220,27 @@ async function maybePersistAllowlist( skillId: string, ): Promise { const result = upsertDedicatedAgentSkillAllowlist(config, agentId, skillId); - if (result.changed && typeof api.runtime?.config?.writeConfigFile === "function") { - await api.runtime.config.writeConfigFile(result.nextConfig); + if (result.changed) { + const cfg = api.runtime?.config; + // OpenClaw's runtime APIs take a params object, not the config directly: + // replaceConfigFile({ nextConfig, ... }) + // mutateConfigFile({ mutate: (draft) => { ...mutate in place... } }) + // Passing the config bare causes OpenClaw to destructure it as params and + // see `params.nextConfig === undefined`, which fails schema validation. + if (typeof cfg?.replaceConfigFile === "function") { + await cfg.replaceConfigFile({ nextConfig: result.nextConfig }); + } else if (typeof cfg?.mutateConfigFile === "function") { + await cfg.mutateConfigFile({ + mutate: (draft) => { + const next = result.nextConfig as Record; + const target = draft as Record; + for (const key of Object.keys(target)) delete target[key]; + for (const [key, value] of Object.entries(next)) target[key] = value; + }, + }); + } else if (typeof cfg?.writeConfigFile === "function") { + await cfg.writeConfigFile(result.nextConfig); + } } return { changed: result.changed, diff --git a/openclaw-plugin/src/config.ts b/openclaw-plugin/src/config.ts index d22a810..655d2a0 100644 --- a/openclaw-plugin/src/config.ts +++ b/openclaw-plugin/src/config.ts @@ -11,6 +11,12 @@ export const REQUIRED_CONTEXT_INJECTION = "continuation-skip"; export async function loadEffectiveOpenClawConfig( api: OpenClawPluginApi, ): Promise { + // Prefer config.current() (newer API); fall back to deprecated loadConfig() + // for compat with older OpenClaw runtimes; final fallback is api.config + // (the static snapshot handed to register()). + if (typeof api.runtime?.config?.current === "function") { + return api.runtime.config.current(); + } if (typeof api.runtime?.config?.loadConfig === "function") { return api.runtime.config.loadConfig(); } @@ -42,7 +48,12 @@ export function normalizePluginConfig( sessionDir: asOptionalString(raw.sessionDir), workspaceDir: asOptionalString(raw.workspaceDir), ingestUrl: asOptionalString(raw.ingestUrl), - defaultAgentId: asOptionalString(raw.defaultAgentId) ?? "world2agent", + // Default to "main" so W2A signals lane through the user's existing main + // agent (different sessionKey, no cross-contamination of the user's + // chat session). Operators who want full isolation can set + // `defaultAgentId: "world2agent"` (or any other agent id) in plugin config + // — they then need `openclaw agents add ` to create that agent. + defaultAgentId: asOptionalString(raw.defaultAgentId) ?? "main", provider: asOptionalString((raw as Record).provider), model: asOptionalString((raw as Record).model), requestTimeoutMs: asPositiveInteger(raw.requestTimeoutMs) ?? 120_000, @@ -88,9 +99,22 @@ export function upsertDedicatedAgentSkillAllowlist( }; } - const currentSkills = Array.isArray(currentAgent.skills) - ? [...currentAgent.skills] - : []; + // Only extend an EXISTING allowlist. If the agent has no `skills` field, + // they have no allowlist (= every skill is accessible) and we must NOT + // create one out of nowhere — that would silently restrict the agent to + // just this one skill, breaking the conversational install flow for any + // future sensor (since `world2agent-manage` would no longer be reachable). + // The dispatcher's prompt-prefix fallback (`Use skill: `) covers the + // no-allowlist case correctly. + if (!Array.isArray(currentAgent.skills)) { + return { + changed: false, + nextConfig: config, + warning: null, + }; + } + + const currentSkills = [...currentAgent.skills]; if (currentSkills.includes(skillId)) { return { changed: false, diff --git a/openclaw-plugin/src/dispatch.ts b/openclaw-plugin/src/dispatch.ts index 7ebb112..ec0700f 100644 --- a/openclaw-plugin/src/dispatch.ts +++ b/openclaw-plugin/src/dispatch.ts @@ -1,9 +1,13 @@ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; +import { copyFile, mkdir, writeFile } from "node:fs/promises"; import type { IncomingMessage, ServerResponse } from "node:http"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { hasDedicatedAgentSkillsAllowlist } from "./config.js"; import { renderSignalPrompt } from "./prompt.js"; -import type { OpenClawConfig } from "./openclaw/plugin-sdk/types.js"; +import type { + OpenClawConfig, + OpenClawPluginApi, +} from "./openclaw/plugin-sdk/types.js"; import type { Dispatcher, DispatcherDispatchInput, @@ -13,7 +17,8 @@ import type { } from "./types.js"; const RUN_EMBEDDED_AGENT_ERROR = - "M0 spike unverified: api.runtime.agent.runEmbeddedAgent not found — verify against a live OpenClaw install"; + "OpenClaw runtime is missing api.runtime.agent.runEmbeddedAgent. Verify the " + + "plugin is running inside a live OpenClaw gateway."; export function assertEmbeddedAgentRuntime(options: EmbeddedDispatcherOptions): void { if (typeof options.api.runtime?.agent?.runEmbeddedAgent !== "function") { @@ -54,7 +59,7 @@ export class EmbeddedDispatcher implements Dispatcher { input: DispatcherDispatchInput, sessionId: string, ): Promise { - const prompt = renderSignalPrompt(input.signal, { + const signalText = renderSignalPrompt(input.signal, { skillId: input.skillId, useSkillPrefix: !hasDedicatedAgentSkillsAllowlist( this.options.openclawConfigRef.current, @@ -63,33 +68,84 @@ export class EmbeddedDispatcher implements Dispatcher { }); const config = this.options.openclawConfigRef.current; - const runtimeAgent = this.options.api.runtime!.agent!; + const api = this.options.api; + const runtimeAgent = api.runtime?.agent; const agentId = this.options.pluginConfig.defaultAgentId ?? "main"; - const sessionKey = `w2a:${input.sensorId}`; + // sessionKey is the colon-namespaced lane OpenClaw uses for system-event + // routing and heartbeat targeting. `agent::` matches + // OpenClaw's standard shape (e.g. main agent's chat is `agent:main:main`). + const sessionKey = `agent:${agentId}:${sessionId}`; const openclawHome = this.options.paths.openclawHome; + const agentDir = + tryCall(() => runtimeAgent?.resolveAgentDir?.(config, agentId)) ?? + join(openclawHome, "agents", agentId); + + const sessionsApi = runtimeAgent?.session; + const sessionFile = + tryCall(() => + sessionsApi?.resolveSessionFilePath?.(sessionId, undefined, { agentId }), + ) ?? join(agentDir, "sessions", `${sessionId}.jsonl`); + + const { provider, model } = resolveProviderAndModel( + config, + this.options.pluginConfig, + ); + + // ───────────────────────────────────────────────────────────────── + // Dispatch via runEmbeddedAgent + `# System Event` prompt prefix. + // + // Originally we tried OpenClaw's enqueueSystemEvent + requestHeartbeatNow + // to inject the signal as a true system message (matching the spirit of + // claude-code-channel's `notifications/claude/channel`). In OpenClaw + // 2026.4.26 that path *does* spawn a turn and *does* drain the queued + // event — but `drainFormattedSystemEvents` materializes the event as a + // text block prefixed with `System:` lines and **injects it into the + // user-role prompt**, not into a real system message. Net result: the + // signal still occupies user-role position in the transcript, just + // prefixed with the literal characters "System:". + // + // Until OpenClaw exposes a plugin API that writes a true system-role + // message, we use runEmbeddedAgent and frame the prompt inline. The + // agent treats the `# System Event` block as an external notification + // thanks to the framing, even though it lives in user-role position. + // ───────────────────────────────────────────────────────────────── + if (typeof runtimeAgent?.runEmbeddedAgent !== "function") { + throw new Error( + "OpenClaw runtime does not expose runEmbeddedAgent — this plugin cannot dispatch signals against this OpenClaw version.", + ); + } + const workspaceDir = this.options.pluginConfig.workspaceDir ?? tryCall(() => runtimeAgent.resolveAgentWorkspaceDir?.(config, agentId)) ?? join(openclawHome, "workspace"); - const agentDir = - tryCall(() => runtimeAgent.resolveAgentDir?.(config, agentId)) ?? - join(openclawHome, "agents", agentId); - const sessionFile = join(agentDir, "sessions", `${sessionId}.jsonl`); const timeoutMs = this.options.pluginConfig.requestTimeoutMs ?? tryCall(() => runtimeAgent.resolveAgentTimeoutMs?.(config)) ?? 120_000; - // OpenClaw's runEmbeddedAgent silently defaults to "openai/gpt-5.4" when - // provider/model are absent — it does NOT read agents.defaults.model.primary. - // Resolve the effective default ourselves so signal-driven runs follow the - // operator's configured model. - const { provider, model } = resolveProviderAndModel( - config, - this.options.pluginConfig, - ); + await ensureSessionRegistered({ + api, + agentId, + sessionId, + sessionKey, + sessionFile, + sensorId: input.sensorId, + provider, + model, + }); + + const promptForTurn = + "# System Event\n\n" + + "The following is an external event delivered by a World2Agent sensor. " + + "It is NOT a user request — do not address the user as if they typed " + + "this message. Load the referenced skill and apply its rules: the skill " + + "owns the policy for when to reply, how to format, and when to stay " + + "quiet. Defer to the skill, not to your own judgment about relevance.\n\n" + + "---\n\n" + + signalText; - return runtimeAgent.runEmbeddedAgent!({ + const result = await runtimeAgent.runEmbeddedAgent!({ sessionId, sessionKey, agentId, @@ -98,11 +154,137 @@ export class EmbeddedDispatcher implements Dispatcher { workspaceDir, agentDir, config, - prompt, + prompt: promptForTurn, timeoutMs, ...(provider ? { provider } : {}), ...(model ? { model } : {}), } as Parameters>[0]); + + await mirrorIsolatedSessionFiles(agentDir, sessionId).catch(() => undefined); + await ensureSessionRegistered({ + api, + agentId, + sessionId, + sessionKey, + sessionFile, + sensorId: input.sensorId, + provider, + model, + }).catch(() => undefined); + + return { ok: true, path: "embedded", result }; + } +} + +async function ensureSessionRegistered(params: { + api: OpenClawPluginApi; + agentId: string; + sessionId: string; + sessionKey: string; + sessionFile: string; + sensorId: string; + provider?: string; + model?: string; +}): Promise { + const now = Date.now(); + + // Build the entry once, used by both paths below. + const entryFor = (existing?: Record): Record => + existing + ? { ...existing, updatedAt: now, lastInteractionAt: now } + : { + sessionId: params.sessionId, + sessionFile: params.sessionFile, + sessionStartedAt: now, + startedAt: now, + updatedAt: now, + lastInteractionAt: now, + endedAt: null, + status: "idle", + origin: "world2agent", + chatType: "embedded", + lastChannel: "world2agent", + agentHarnessId: "claude-cli", + ...(params.model ? { model: params.model } : {}), + ...(params.provider ? { modelProvider: params.provider } : {}), + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheRead: 0, + cacheWrite: 0, + contextTokens: 0, + runtimeMs: 0, + }; + + // Try OpenClaw's session-store API first (preferred — it integrates with + // OpenClaw's in-memory caches). Best-effort; if it silently no-ops or + // throws we still get the file via the unconditional direct write below. + const sessionsApi = params.api.runtime?.agent?.session; + const load = sessionsApi?.loadSessionStore; + const save = sessionsApi?.saveSessionStore; + if (typeof load === "function" && typeof save === "function") { + try { + const store = (await load(params.agentId)) as Record; + store[params.sessionKey] = entryFor( + store[params.sessionKey] as Record | undefined, + ); + await save(params.agentId, store); + } catch { + // ignore — direct write below is the source of truth + } + } + + // ALWAYS write sessions.json directly. OpenClaw's `openclaw sessions + // --agent ` and the dashboard read this file; a plugin-side + // saveSessionStore call is opaque and can no-op silently in some + // OpenClaw versions, so we don't trust it as the only mechanism. + const sessionFileDir = dirname(params.sessionFile); + const storePath = join(sessionFileDir, "sessions.json"); + let raw: Record = {}; + try { + const fs = await import("node:fs/promises"); + const txt = await fs.readFile(storePath, "utf8"); + raw = JSON.parse(txt) as Record; + } catch { + raw = {}; + } + raw[params.sessionKey] = entryFor( + raw[params.sessionKey] as Record | undefined, + ); + await mkdir(sessionFileDir, { recursive: true }); + await writeFile(storePath, JSON.stringify(raw, null, 2) + "\n", "utf8"); +} + +async function mirrorIsolatedSessionFiles( + agentDir: string, + sessionId: string, +): Promise { + // runEmbeddedAgent writes to `/agent/sessions/.{jsonl,trajectory.jsonl,trajectory-path.json}`. + // OpenClaw's user-facing session viewer reads from `/sessions/.jsonl`. + // Mirror the three files so the dashboard can render the conversation. + const isolatedDir = join(agentDir, "agent", "sessions"); + const standardDir = join(agentDir, "sessions"); + await mkdir(standardDir, { recursive: true }); + for (const suffix of [".jsonl", ".trajectory.jsonl", ".trajectory-path.json"] as const) { + const src = join(isolatedDir, `${sessionId}${suffix}`); + const dst = join(standardDir, `${sessionId}${suffix}`); + try { + await copyFile(src, dst); + } catch { + // best-effort; missing files are fine for sessions that haven't been + // written yet (e.g. early failure in runEmbeddedAgent). + } + } + // Rewrite the trajectory pointer so it references the canonical path. + try { + const ptrPath = join(standardDir, `${sessionId}.trajectory-path.json`); + const fs = await import("node:fs/promises"); + const ptrText = await fs.readFile(ptrPath, "utf8"); + const ptr = JSON.parse(ptrText) as Record; + ptr.runtimeFile = join(standardDir, `${sessionId}.trajectory.jsonl`); + await fs.writeFile(ptrPath, JSON.stringify(ptr, null, 2), "utf8"); + } catch { + // ignore — pointer is non-critical } } diff --git a/openclaw-plugin/src/install.ts b/openclaw-plugin/src/install.ts index 2a007bf..833d854 100644 --- a/openclaw-plugin/src/install.ts +++ b/openclaw-plugin/src/install.ts @@ -76,8 +76,19 @@ export async function writeGeneratedSkill( paths: World2AgentPaths, pkg: string, installed: InstalledPackageInfo, -): Promise { +): Promise<{ skillId: string; written: boolean }> { const skillId = packageToSkillId(pkg); + const skillDir = join(paths.openclawSkillsDir, skillId); + const skillFile = join(skillDir, "SKILL.md"); + + // Never clobber a SKILL.md the user (or the world2agent-manage skill running + // the SETUP.md Q&A flow) already wrote — that personalized version is + // strictly better than a generic fallback. The CLI also accepts + // --skip-generate-skill for the same purpose; this is the safety net. + if (await pathExists(skillFile)) { + return { skillId, written: false }; + } + const sourceType = String( (installed.packageJson.w2a as Record | undefined)?.source_type ?? pkg, ); @@ -87,7 +98,6 @@ export async function writeGeneratedSkill( | undefined )?.join(", "); - const skillDir = join(paths.openclawSkillsDir, skillId); await mkdir(skillDir, { recursive: true }); const skillMd = [ "---", @@ -106,31 +116,50 @@ export async function writeGeneratedSkill( "", "## Behavior", "- Parse the JSON when you need structured fields.", - "- If the signal is irrelevant or obviously low-value, skip silently.", - "- If it is actionable, reply briefly with the key fact, why it matters, and any obvious next step.", + "- Default: reply with one short line — the key fact and why it might matter.", + "- The user has not personalized this handler yet, so do NOT silently skip on subjective relevance grounds. Reply briefly even if the signal seems mundane.", + "- The user can replace this file at `~/.openclaw/skills/" + skillId + "/SKILL.md` to add filtering rules (e.g. topics they care about), depth preferences, or output format.", "", "## Notes", - "- This skill was generated by @world2agent/openclaw-plugin because the sensor package does not ship a richer OpenClaw-specific handler yet.", - "- If the dedicated World2Agent agent does not expose a skills allowlist, the plugin falls back to `Use skill: ` prompt routing.", + "- This skill is the auto-generated fallback. The richer path is to let `world2agent-manage` walk the user through the sensor's `SETUP.md` Q&A — that produces a personalized SKILL.md in this exact location.", "", ].join("\n"); - await writeFile(join(skillDir, "SKILL.md"), skillMd, "utf8"); - return skillId; + await writeFile(skillFile, skillMd, "utf8"); + return { skillId, written: true }; } export async function loadConfigFile( configFile: string | undefined, + configJson: string | undefined, installed: InstalledPackageInfo, ): Promise> { + // Inline --config-json takes precedence over --config-file. Lets users + // provide config without managing a temp file: + // sensor add @pkg --config-json '{"top_n":10,"min_score":50}' + if (configJson !== undefined && configJson !== "") { + let parsed: unknown; + try { + parsed = JSON.parse(configJson); + } catch (error) { + throw new Error( + `--config-json is not valid JSON: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`--config-json must parse as a JSON object`); + } + return parsed as Record; + } + if (!configFile) { const setupPath = String( (installed.packageJson.w2a as Record | undefined)?.setup ?? "SETUP.md", ); throw new Error( - `Interactive setup is not implemented; use --config-file . Sensor guidance: ${join( - installed.packageRoot, - setupPath, - )}`, + `Provide either --config-json '' inline or --config-file . ` + + `Sensor guidance: ${join(installed.packageRoot, setupPath)}`, ); } diff --git a/openclaw-plugin/src/manifest.ts b/openclaw-plugin/src/manifest.ts index 6109759..7ae585b 100644 --- a/openclaw-plugin/src/manifest.ts +++ b/openclaw-plugin/src/manifest.ts @@ -68,7 +68,7 @@ export function normalizeSensorEntry(entry: SensorEntry): SensorEntry { return { sensor_id: entry.sensor_id, pkg: entry.pkg, - skill_id: packageToSkillId(entry.pkg), + skill_id: entry.skill_id?.trim() ? entry.skill_id : packageToSkillId(entry.pkg), enabled: entry.enabled !== false, isolated: entry.isolated === true, config: entry.config ?? {}, diff --git a/openclaw-plugin/src/openclaw/plugin-sdk/types.ts b/openclaw-plugin/src/openclaw/plugin-sdk/types.ts index e96b32a..473a0b0 100644 --- a/openclaw-plugin/src/openclaw/plugin-sdk/types.ts +++ b/openclaw-plugin/src/openclaw/plugin-sdk/types.ts @@ -59,7 +59,53 @@ export interface OpenClawPluginLogger { } export interface OpenClawRuntimeAgentSessionApi { - resolveSessionFilePath?(config: OpenClawConfig, sessionId: string): string; + resolveSessionFilePath?( + sessionId: string, + entry?: { sessionFile?: string }, + opts?: { agentId?: string; sessionsDir?: string }, + ): string; + resolveStorePath?( + store?: string, + opts?: { agentId?: string }, + ): string; + loadSessionStore?(agentId: string): Promise>; + saveSessionStore?( + agentId: string, + store: Record, + ): Promise; +} + +export interface OpenClawRuntimeSystemApi { + /** + * Enqueue a system event for a given session. OpenClaw drains queued + * system events at the start of the next agent turn and prepends them to + * the prompt as `System:` lines — this is the canonical way for plugins + * to inject context as a system notification rather than as user input. + */ + enqueueSystemEvent?( + text: string, + options: { + sessionKey: string; + contextKey?: string | null; + trusted?: boolean; + }, + ): boolean; + /** + * Wake the agent for a specific session/lane. OpenClaw's heartbeat + * handler will spin up a turn for that sessionKey, automatically draining + * the queued system events into the turn's prompt as `System:` lines. + * This is the same mechanism the bundled cron plugin uses to inject + * scheduled events into the agent. + * + * Fire-and-forget — does NOT await turn completion. + */ + requestHeartbeatNow?(opts?: { + reason?: string; + coalesceMs?: number; + agentId?: string; + sessionKey?: string; + heartbeat?: { target?: string }; + }): void; } export interface OpenClawRuntimeAgentApi { @@ -70,9 +116,40 @@ export interface OpenClawRuntimeAgentApi { session?: OpenClawRuntimeAgentSessionApi; } +export interface OpenClawConfigWriteOptions { + afterWrite?: { mode?: "auto" | "skip" | "refresh" } & Record; + [key: string]: unknown; +} + +export interface OpenClawReplaceConfigFileParams { + nextConfig: OpenClawConfig; + baseHash?: string; + afterWrite?: OpenClawConfigWriteOptions["afterWrite"]; + writeOptions?: OpenClawConfigWriteOptions; +} + +export interface OpenClawMutateConfigFileParams { + mutate: ( + draft: OpenClawConfig, + ctx: { snapshot: unknown; previousHash: string }, + ) => unknown | Promise; + base?: "runtime" | "source"; + baseHash?: string; + afterWrite?: OpenClawConfigWriteOptions["afterWrite"]; + writeOptions?: OpenClawConfigWriteOptions; +} + export interface OpenClawRuntimeConfigApi { + /** @deprecated Use current() instead. */ loadConfig?(): Promise; - writeConfigFile?(config: OpenClawConfig): Promise; + current?(): OpenClawConfig; + /** @deprecated Use mutateConfigFile / replaceConfigFile instead. */ + writeConfigFile?( + config: OpenClawConfig, + options?: OpenClawConfigWriteOptions, + ): Promise; + mutateConfigFile?(params: OpenClawMutateConfigFileParams): Promise; + replaceConfigFile?(params: OpenClawReplaceConfigFileParams): Promise; } export interface CliCommandBuilder { @@ -126,6 +203,7 @@ export interface OpenClawPluginApi { runtime?: { agent?: OpenClawRuntimeAgentApi; config?: OpenClawRuntimeConfigApi; + system?: OpenClawRuntimeSystemApi; }; } diff --git a/openclaw-plugin/src/runtime.ts b/openclaw-plugin/src/runtime.ts index dd9785b..fe9368e 100644 --- a/openclaw-plugin/src/runtime.ts +++ b/openclaw-plugin/src/runtime.ts @@ -87,10 +87,25 @@ export class SensorRuntime { const store = new FileSensorStore({ path: join(this.paths.stateDir, `${entry.sensor_id}.json`), }); + // Sensor logger writes to stderr (via this.log → OpenClaw's logger), NEVER stdout. + // stdout in the gateway process is shared with the user's terminal during + // interactive commands like `openclaw agents add`, and noisy sensor logs + // would corrupt those interactive prompts. + const sensorLog = (line: string) => this.log(`[w2a/${entry.sensor_id}] ${line}`); + const sensorLogger = { + info: (msg: string, ...args: unknown[]) => + sensorLog(args.length > 0 ? `${msg} ${args.map(String).join(" ")}` : msg), + warn: (msg: string, ...args: unknown[]) => + sensorLog(args.length > 0 ? `WARN ${msg} ${args.map(String).join(" ")}` : `WARN ${msg}`), + error: (msg: string, ...args: unknown[]) => + sensorLog(args.length > 0 ? `ERROR ${msg} ${args.map(String).join(" ")}` : `ERROR ${msg}`), + debug: (msg: string, ...args: unknown[]) => + sensorLog(args.length > 0 ? `DEBUG ${msg} ${args.map(String).join(" ")}` : `DEBUG ${msg}`), + }; const cleanup = await startSensor(spec, { config: entry.config, store, - logger: console, + logger: sensorLogger, logEmits: true, onSignal: async (signal) => { try { @@ -99,9 +114,12 @@ export class SensorRuntime { skillId: entry.skill_id, signal, }); + this.log( + `[w2a/${entry.sensor_id}] dispatched ${signal.signal_id} [${signal.event?.type ?? "unknown"}]`, + ); } catch (error) { this.log( - `[w2a/${entry.sensor_id}] dispatch failed: ${errorMessage(error)}`, + `[w2a/${entry.sensor_id}] dispatch failed for ${signal.signal_id}: ${errorMessage(error)}`, ); } }, diff --git a/openclaw-plugin/test/dispatch.test.ts b/openclaw-plugin/test/dispatch.test.ts index 5cfb446..2c11083 100644 --- a/openclaw-plugin/test/dispatch.test.ts +++ b/openclaw-plugin/test/dispatch.test.ts @@ -24,7 +24,7 @@ const TEST_SIGNAL = { }; describe("EmbeddedDispatcher", () => { - it("serializes by sensor session and renders the prompt-prefix fallback", async () => { + it("dispatches via runEmbeddedAgent with `# System Event` framed prompt", async () => { const calls: EmbeddedAgentRunRequest[] = []; const dispatcher = new EmbeddedDispatcher({ api: { @@ -35,14 +35,13 @@ describe("EmbeddedDispatcher", () => { return { ok: true }; }), }, + // NO `system` namespace at all → must fall back to embedded path }, }, openclawConfigRef: { current: { agents: { - defaults: { - contextInjection: "continuation-skip", - }, + defaults: { contextInjection: "continuation-skip" }, list: [], }, }, @@ -52,22 +51,21 @@ describe("EmbeddedDispatcher", () => { requestTimeoutMs: 12_345, ingestDedupTtlMs: 3_600_000, }, - paths: makePaths("/tmp/w2a-openclaw-dispatch"), + paths: makePaths("/tmp/w2a-openclaw-dispatch-fallback"), }); - await dispatcher.dispatch({ + const result = (await dispatcher.dispatch({ sensorId: "fake-tick", skillId: "world2agent-sensor-fake-tick", signal: TEST_SIGNAL, - }); + })) as { path: string }; + expect(result.path).toBe("embedded"); expect(calls).toHaveLength(1); expect(calls[0]?.sessionId).toBe("w2a-fake-tick"); - expect(calls[0]?.sessionKey).toBe("w2a:fake-tick"); - expect(calls[0]?.timeoutMs).toBe(12_345); - expect(calls[0]?.prompt.startsWith("Use skill: world2agent-sensor-fake-tick")).toBe( - true, - ); + expect(calls[0]?.sessionKey).toBe("agent:world2agent:w2a-fake-tick"); + expect(calls[0]?.prompt.startsWith("# System Event")).toBe(true); + expect(calls[0]?.prompt).toContain("Use skill: world2agent-sensor-fake-tick"); }); }); diff --git a/openclaw-plugin/test/manifest.test.ts b/openclaw-plugin/test/manifest.test.ts index 52f9ed1..836b42e 100644 --- a/openclaw-plugin/test/manifest.test.ts +++ b/openclaw-plugin/test/manifest.test.ts @@ -11,17 +11,19 @@ import { import type { World2AgentPaths } from "../src/types.js"; describe("manifest helpers", () => { - it("writes and reads a normalized manifest", async () => { + it("writes and reads a normalized manifest, preserving custom skill_id", async () => { const root = await mkdtemp(join(tmpdir(), "w2a-openclaw-manifest-")); const paths = makePaths(root); + // Custom skill_id must be preserved end-to-end (matches hermes-bridge + // behavior; previously this PR overrode it with packageToSkillId). await writeManifest(paths, { version: 1, sensors: [ { sensor_id: "hackernews", pkg: "@world2agent/sensor-hackernews", - skill_id: "ignored-on-write", + skill_id: "my-custom-handler", enabled: true, isolated: true, config: { interval_ms: 30_000 }, @@ -36,7 +38,7 @@ describe("manifest helpers", () => { { sensor_id: "hackernews", pkg: "@world2agent/sensor-hackernews", - skill_id: "world2agent-sensor-hackernews", + skill_id: "my-custom-handler", enabled: true, isolated: true, config: { interval_ms: 30_000 }, @@ -45,6 +47,28 @@ describe("manifest helpers", () => { }); }); + it("falls back to packageToSkillId when skill_id is empty", async () => { + const root = await mkdtemp(join(tmpdir(), "w2a-openclaw-manifest-")); + const paths = makePaths(root); + + await writeManifest(paths, { + version: 1, + sensors: [ + { + sensor_id: "hackernews", + pkg: "@world2agent/sensor-hackernews", + skill_id: "", + enabled: true, + isolated: false, + config: {}, + }, + ], + }); + + const manifest = await readManifest(paths); + expect(manifest.sensors[0]?.skill_id).toBe("world2agent-sensor-hackernews"); + }); + it("upserts and removes entries by sensor id", () => { const initial = { version: 1 as const, From 44bba07cc476c77b28e6882eea7de53743c6a34c Mon Sep 17 00:00:00 2001 From: Nova-machinepulse Date: Wed, 29 Apr 2026 16:52:29 +0800 Subject: [PATCH 3/9] docs(openclaw-plugin): document in root README and refine onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Root README: new openclaw-plugin row in the package table, a full "Quick start — OpenClaw" section modeled on the Claude / Hermes ones (contextInjection prereq, conversational install, gateway restart caveat, w2a-* session lane pointer), Repository layout entry, and an Updating section for the npm publish flow. - openclaw-plugin SKILL.md: Step 7 now hands `openclaw gateway restart` back to the user instead of running it from inside the chat — running it there kills the gateway and truncates the agent's reply. - openclaw-plugin README: "Where to view" section rewritten to match B-mode dispatch (runEmbeddedAgent + `# System Event` framing) and to tell users explicitly to switch to the `w2a-` session lane in dashboard; main chat lane is intentionally untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 65 +++++++++++++++++-- openclaw-plugin/README.md | 43 ++++++++---- .../skills/world2agent-manage/SKILL.md | 40 +++++++++--- 3 files changed, 121 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 5eeb2ab..ea0f424 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Channel adapters that connect [World2Agent](https://github.com/machinepulse-ai/w | --- | --- | --- | | [`claude-code-channel`](./claude-code-channel) | [Claude Code](https://docs.claude.com/en/docs/claude-code) | MCP channel adapter + Claude Code plugin bundle. Signals arrive as in-session MCP notifications. | | [`hermes-sensor-bridge`](./hermes-sensor-bridge) | [Hermes Agent](https://hermes-agent.nousresearch.com/) | Out-of-process supervisor + webhook bridge. Each signal triggers a fresh `AIAgent.run_conversation()` with the generated handler skill auto-loaded. | +| [`openclaw-plugin`](./openclaw-plugin) | [OpenClaw](https://docs.openclaw.ai/) | Native OpenClaw plugin. Conversational install via chat (Q&A driven by the sensor's `SETUP.md`), in-process polling, dispatch via `runEmbeddedAgent` into a per-sensor session lane keyed `agent:main:w2a-` (main chat untouched). | --- @@ -51,6 +52,37 @@ Each signal triggers a fresh agent run against the generated handler skill. See --- +## Quick start — OpenClaw + +Prereq — the plugin refuses to start unless `agents.defaults.contextInjection` is exactly `"continuation-skip"` in `~/.openclaw/openclaw.json`. This is hard-fail by design (the default `"always"` re-injects bootstrap on every signal and silently turns sensors into a token sink): + +```bash +jq '.agents.defaults.contextInjection = "continuation-skip"' \ + ~/.openclaw/openclaw.json > /tmp/openclaw.json.tmp && \ + mv /tmp/openclaw.json.tmp ~/.openclaw/openclaw.json +``` + +Install the plugin (`--dangerously-force-unsafe-install` is required because the plugin uses `child_process` to npm-install sensor packages on demand — OpenClaw's security scan blocks it otherwise): + +```bash +openclaw plugins install @world2agent/openclaw-plugin --dangerously-force-unsafe-install +openclaw gateway restart +``` + +Then in a chat session with your `main` agent, just describe what you want to subscribe to: + +``` +> 帮我订阅 Hacker News,我关心 AI 和安全话题 +``` + +The bundled `world2agent-manage` skill takes over: reads the sensor's `SETUP.md`, asks you 1–3 questions to personalize the handler, writes both the config and the personalized SKILL.md, and registers the sensor — without any manual CLI work. + +> First time only: the agent will ask **you** to run `openclaw gateway restart` once after registration. It intentionally doesn't run that command itself — restarting the gateway from inside the chat would kill the gateway process and truncate the agent's reply mid-sentence. After the restart, the sensor starts polling within ~60 seconds. + +Signals route to a per-sensor session lane (`agent:main:w2a-`) — your `main` chat is untouched. Open the `w2a-` lane in the OpenClaw dashboard () to see how the agent reacts to each signal. See [`openclaw-plugin/README.md`](./openclaw-plugin/README.md) for the full install reference, dispatcher internals, and CLI fallback. + +--- + ## Repository layout ``` @@ -63,14 +95,24 @@ Each signal triggers a fresh agent run against the generated handler skill. See │ ├── skills/ # MCP-side handler skills │ ├── src/ │ └── package.json -└── hermes-sensor-bridge/ # @world2agent/hermes-sensor-bridge +├── hermes-sensor-bridge/ # @world2agent/hermes-sensor-bridge +│ ├── src/ +│ │ ├── runner/ # per-sensor subprocess +│ │ └── supervisor/ # daemon (signal → HMAC → POST → Hermes) +│ ├── skills/world2agent-manage/ +│ │ ├── SKILL.md # agent-facing skill +│ │ └── scripts/ # all host-side work (install, remove, list, …) +│ ├── e2e/ +│ └── package.json +└── openclaw-plugin/ # @world2agent/openclaw-plugin ├── src/ - │ ├── runner/ # per-sensor subprocess - │ └── supervisor/ # daemon (signal → HMAC → POST → Hermes) + │ ├── dispatch.ts # runEmbeddedAgent + `# System Event` framing + │ ├── runtime.ts # in-process sensor lifecycle + │ ├── isolated.ts # opt-in subprocess mode (reuses Hermes runner) + │ └── cli.ts # `openclaw world2agent sensor add | list | remove` ├── skills/world2agent-manage/ - │ ├── SKILL.md # agent-facing skill - │ └── scripts/ # all host-side work (install, remove, list, …) - ├── e2e/ + │ └── SKILL.md # conversational install + management skill + ├── test/ └── package.json ``` @@ -93,6 +135,17 @@ Users pull updates with: Bump `version` in `hermes-sensor-bridge/package.json`, then `pnpm publish --access public --tag alpha` (alpha) or `latest` (stable). Users pull the runtime with `npm install -g @world2agent/hermes-sensor-bridge@`. The skill is installed separately via `hermes skills install …`; re-run that command with `--force` to refresh to the latest skill content. +### OpenClaw plugin (`openclaw-plugin`) + +Bump `version` in `openclaw-plugin/package.json`, then `pnpm publish --access public --tag alpha` (alpha) or `latest` (stable). Users pull updates with: + +```bash +openclaw plugins install @world2agent/openclaw-plugin@ --dangerously-force-unsafe-install +openclaw gateway restart +``` + +The bundled `world2agent-manage` skill ships inside the package, so it updates atomically with the plugin — no separate install step. + --- ## License diff --git a/openclaw-plugin/README.md b/openclaw-plugin/README.md index 7fb578d..32dd585 100644 --- a/openclaw-plugin/README.md +++ b/openclaw-plugin/README.md @@ -83,7 +83,13 @@ The plugin ships a `world2agent-manage` skill that activates on this kind of int 2. Ask you 1–3 questions defined in that file (poll thresholds, your topics of interest, reply depth) 3. Fill the SKILL.md template with your answers and write it to `~/.openclaw/skills/world2agent-sensor-hackernews/SKILL.md` 4. Run `openclaw world2agent sensor add ... --skip-generate-skill` to register -5. Tell you when the first signal will arrive +5. Ask **you** to run `openclaw gateway restart` in your terminal (the agent + intentionally does NOT run this itself — restarting the gateway from + inside the chat would kill this very chat session mid-reply) +6. Tell you when the first signal will arrive **and which session lane to + open in dashboard** to see the agent's replies (signals route to a + separate `w2a-` session, not your main chat — see + ["Where to view signal-driven agent runs"](#where-to-view-signal-driven-agent-runs) below) This personalized SKILL.md is what makes the agent reply meaningfully to relevant signals (instead of skipping every signal silently because it has no anchor for "what's relevant to this user"). @@ -105,30 +111,45 @@ openclaw world2agent reload # falls back to `openclaw gateway restart` if reload times out ``` -Within ~60 seconds the sensor will start polling. Each emitted signal triggers an agent turn under sessionKey `agent:main:w2a-`, with the signal framed as a `# System Event` block. +> ⚠️ **Run the restart in your own terminal — never inside an OpenClaw chat session.** `openclaw gateway restart` kills the gateway process, which terminates any in-flight chat reply mid-sentence. The conversational install path explicitly hands the restart back to the user for this reason. + +Within ~60 seconds of the restart, the sensor will start polling. Each emitted signal triggers an agent turn under sessionKey `agent:main:w2a-`, with the signal framed as a `# System Event` block. ## Where to view signal-driven agent runs -Each sensor gets a stable session id `w2a-`, scoped to your default agent (`main` unless you overrode `defaultAgentId`). Signals are injected via OpenClaw's system-event queue + heartbeat — **the W2A signal arrives as a `System:` block in the agent turn, not as a user message**. You can view the resulting conversation in three ways: +Each sensor gets its own session lane, separate from your main chat. The +lane is keyed `agent::w2a-` (e.g. +`agent:main:w2a-hackernews`), with stable session id `w2a-`. The +plugin dispatches signals via `runEmbeddedAgent` with a `# System Event` +markdown frame in the prompt, so the signal lives in user-role position +within the W2A session — but **never in your main chat session**. + +Concrete: if you ran the conversational install for Hacker News, look for +the `w2a-hackernews` session, not `main`. Your `main` chat is untouched. ```bash -# CLI — list W2A sessions on the main agent +# CLI — list W2A sessions on the main agent (with last-active filter) openclaw sessions --agent main --active 60 -# (or `--agent world2agent` if you set defaultAgentId to a dedicated agent) +# expected to include: w2a-hackernews -# Dashboard — open the OpenClaw control UI +# Dashboard — open OpenClaw's control UI, then switch to the +# `w2a-hackernews` (or w2a-) session in the sidebar open http://127.0.0.1:18789/ # Direct file access (for debugging) ls ~/.openclaw/agents/main/sessions/ -# w2a-hackernews.jsonl ← session metadata +# w2a-hackernews.jsonl ← signal-handling transcript # w2a-hackernews.trajectory.jsonl ← full LLM tool-call trajectory -# sessions.json ← OpenClaw session index (includes -# `agent:main:w2a-` lanes -# alongside `agent:main:main` chat lane) +# sessions.json ← OpenClaw session index (lists both +# `agent:main:main` chat lane AND +# `agent:main:w2a-` lanes) ``` -Your normal chat with `main` agent (sessionKey `agent:main:main`) is **untouched** — W2A signals only show up under `agent:main:w2a-` lanes. +Your normal chat with the `main` agent (sessionKey `agent:main:main`) is +**untouched** — W2A signals only show up under `agent:main:w2a-` +lanes. Open one of those lanes to see how the agent is reacting to +incoming signals; that's where you'll spot whether your handler SKILL.md +needs tuning. ## Scope diff --git a/openclaw-plugin/skills/world2agent-manage/SKILL.md b/openclaw-plugin/skills/world2agent-manage/SKILL.md index 15b660d..e7b8dc5 100644 --- a/openclaw-plugin/skills/world2agent-manage/SKILL.md +++ b/openclaw-plugin/skills/world2agent-manage/SKILL.md @@ -151,16 +151,36 @@ Optional flags: - `--isolated` to run the sensor out-of-process (for unstable third-party sensors) -### Step 7 — Confirm and reload - -If the CLI's `reload` field returns `ok: true`, the sensor is polling. If -not, ask the user to run `openclaw gateway restart` (the plugin process -caches its config at register time). - -Tell the user: -- which sensor id was created -- where the personalized SKILL.md lives -- when to expect the first signal (sensor's poll interval) +### Step 7 — Confirm and tell the user how to activate + +If the CLI's `reload` field returns `ok: true`, the sensor is already +polling — done. + +If reload failed (timeout is the common case), **DO NOT run +`openclaw gateway restart` yourself**. Restarting the gateway from inside +this chat would kill the gateway process — including this very chat +session — and the user would see your reply truncated mid-sentence. Always +hand the restart back to the user. Tell them, in their language, something +like: + +> 装好了。新 sensor 需要 gateway 重启才会开始 polling。请你在终端跑 +> `openclaw gateway restart`(我没法自己跑这条命令——它会把当前这个 +> chat session 一起重启,对话会被截断)。重启后 ~60 秒内会拉到第一条信号。 + +Then summarize for the user: + +- **sensor id** that was created (e.g. `hackernews`) +- **where the personalized SKILL.md lives** (`~/.openclaw/skills//SKILL.md`) + so they know what to edit later if their preferences change +- **where signal-driven runs will appear**: signals do NOT pollute their + main chat lane. Each sensor gets its own session lane keyed + `agent:main:w2a-` (sessionId `w2a-`). Tell the + user to switch to that lane in dashboard + () — or run + `openclaw sessions --agent main --active 60` from CLI — to see + the agent's responses to incoming signals. The SKILL.md they just + configured drives those replies. +- **when to expect the first signal** based on the sensor's poll interval ## List sensors From 5568c1202cbcff2c3f6bf9bbbd3a0fb2fdcb648a Mon Sep 17 00:00:00 2001 From: Nova-machinepulse Date: Wed, 29 Apr 2026 17:32:50 +0800 Subject: [PATCH 4/9] docs(openclaw-plugin): replace Chinese examples with English equivalents The conversational-install docs (root README, openclaw-plugin README, and the world2agent-manage SKILL.md) had a Chinese sample utterance and a Chinese gateway-restart prompt that the agent was supposed to repeat verbatim. Replace both with English so the docs stay consistent in one language and so the SKILL.md sample reads as a template the agent delivers in the user's actual language, not a prescribed Chinese reply. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- openclaw-plugin/README.md | 2 +- openclaw-plugin/skills/world2agent-manage/SKILL.md | 14 ++++++++------ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ea0f424..2c9b5d6 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ openclaw gateway restart Then in a chat session with your `main` agent, just describe what you want to subscribe to: ``` -> 帮我订阅 Hacker News,我关心 AI 和安全话题 +> subscribe me to Hacker News — I care about AI and security stories ``` The bundled `world2agent-manage` skill takes over: reads the sensor's `SETUP.md`, asks you 1–3 questions to personalize the handler, writes both the config and the personalized SKILL.md, and registers the sensor — without any manual CLI work. diff --git a/openclaw-plugin/README.md b/openclaw-plugin/README.md index 32dd585..42dacf7 100644 --- a/openclaw-plugin/README.md +++ b/openclaw-plugin/README.md @@ -74,7 +74,7 @@ openclaw chat --agent main ``` ``` -> 帮我订阅 Hacker News,我关心 AI 和安全话题 +> subscribe me to Hacker News — I care about AI and security stories ``` The plugin ships a `world2agent-manage` skill that activates on this kind of intent. Main agent will: diff --git a/openclaw-plugin/skills/world2agent-manage/SKILL.md b/openclaw-plugin/skills/world2agent-manage/SKILL.md index e7b8dc5..8873181 100644 --- a/openclaw-plugin/skills/world2agent-manage/SKILL.md +++ b/openclaw-plugin/skills/world2agent-manage/SKILL.md @@ -160,12 +160,14 @@ If reload failed (timeout is the common case), **DO NOT run `openclaw gateway restart` yourself**. Restarting the gateway from inside this chat would kill the gateway process — including this very chat session — and the user would see your reply truncated mid-sentence. Always -hand the restart back to the user. Tell them, in their language, something -like: - -> 装好了。新 sensor 需要 gateway 重启才会开始 polling。请你在终端跑 -> `openclaw gateway restart`(我没法自己跑这条命令——它会把当前这个 -> chat session 一起重启,对话会被截断)。重启后 ~60 秒内会拉到第一条信号。 +hand the restart back to the user. Tell them — in **their** language, not +necessarily English — something equivalent to: + +> The sensor is registered, but it needs a gateway restart before it +> starts polling. Please run `openclaw gateway restart` in your terminal +> (I can't run it myself — that command would kill this chat session +> mid-reply). The first signal will arrive within ~60 seconds after the +> restart. Then summarize for the user: From 24a422a39aa4f235cb499a613854d8cdfa8cf601 Mon Sep 17 00:00:00 2001 From: Nova-machinepulse Date: Wed, 29 Apr 2026 19:14:03 +0800 Subject: [PATCH 5/9] feat(openclaw-plugin): push sensor replies to a chat platform via deliver config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional `deliver: { channel, to, accountId?, threadId? }` block at plugin-level (`~/.openclaw/openclaw.json`) and per-sensor (manifest entry + `sensor add --deliver-channel/--deliver-to/...` CLI flags). When set, the plugin (a) writes lastChannel/lastTo/deliveryContext onto the W2A session entry and (b) dispatches via `runtime.subagent.run({ deliver: true })` so OpenClaw's resolveAgentDeliveryPlan + deliverAgentCommandResult routes the final assistant reply through the matching channel plugin (lark/feishu/ whatsapp/telegram/...) — no second LLM call, no plugin-side IM client. Falls back to the prior `runtime.agent.runEmbeddedAgent` path when deliver is absent OR when the runtime predates `subagent.run`, so existing deployments are unaffected. Drive-by fix: stop pinning `agentHarnessId: "claude-cli"` on the W2A session entry. That field, once persisted, made OpenClaw refuse the run on hosts where claude-cli isn't a registered harness ("Requested agent harness 'claude-cli' is not registered"). Also strip a stale value from existing entries on the next dispatch so users don't have to hand-edit sessions.json after upgrading. The `world2agent-manage` skill now runs `openclaw plugins list --json` at install time to detect enabled inbound channels (channelIds non-empty) and asks the user once whether to push that sensor's replies to one of them — chat id is always asked from the user, never invented. 13 vitest tests cover plugin/per-sensor deliver wiring, deliveryContext session persistence, the new subagent.run path, and the rejection of provider/model overrides on subagent.run. Co-Authored-By: Claude Opus 4.7 (1M context) --- openclaw-plugin/README.md | 55 ++++++ openclaw-plugin/openclaw.plugin.json | 24 +++ .../skills/world2agent-manage/SKILL.md | 38 ++++ openclaw-plugin/src/cli.ts | 30 +++ openclaw-plugin/src/config.ts | 27 ++- openclaw-plugin/src/dispatch.ts | 166 ++++++++++++++--- openclaw-plugin/src/manifest.ts | 5 + .../src/openclaw/plugin-sdk/types.ts | 39 ++++ openclaw-plugin/src/runtime.ts | 1 + openclaw-plugin/src/types.ts | 11 ++ openclaw-plugin/test/dispatch.test.ts | 176 ++++++++++++++++++ 11 files changed, 544 insertions(+), 28 deletions(-) diff --git a/openclaw-plugin/README.md b/openclaw-plugin/README.md index 42dacf7..1004773 100644 --- a/openclaw-plugin/README.md +++ b/openclaw-plugin/README.md @@ -159,6 +159,61 @@ needs tuning. - Uses a stable per-sensor session id: `w2a-` (and session key `agent::w2a-`). - Requires plugin config `ingestUrl` only when `isolated: true` sensors are used. +## Pushing replies to a chat platform (Lark / WhatsApp / Telegram / …) + +By default a sensor-driven turn stays inside the W2A session lane — the agent's +reply is only visible in `openclaw sessions --agent main` / the dashboard. + +If you've already paired a chat platform (any plugin in `openclaw plugins list +--json` with a non-empty `channelIds` array — feishu, lark, whatsapp, telegram, +discord, slack, signal, imessage, line, msteams, matrix, …), you can have the +plugin route the assistant reply back to that chat. There are two grains: + +**Plugin-wide default** — every sensor's reply lands in the same chat. Set in +this plugin's config block in `~/.openclaw/openclaw.json`: + +```jsonc +{ + "plugins": { + "world2agent": { + "deliver": { + "channel": "feishu", + "to": "oc_chat_xxxxxxxxxxxxxxxx" + } + } + } +} +``` + +**Per-sensor override** — different sensors push to different chats. Pass at +install time: + +```bash +openclaw world2agent sensor add @world2agent/sensor-hackernews \ + --config-json '{"top_n":10,"min_score":50}' \ + --deliver-channel feishu \ + --deliver-to oc_chat_xxxxxxxxxxxxxxxx +``` + +Optional flags: `--deliver-account ` for multi-account channels, +`--deliver-thread ` to post into a specific thread/topic. + +When `deliver` is set, the plugin (a) writes `lastChannel` / `lastTo` / +`deliveryContext` onto the W2A session entry and (b) dispatches via +`runtime.subagent.run({ sessionKey, message, deliver: true })` instead of +the lower-level `runEmbeddedAgent`. The subagent path internally pairs +`runEmbeddedAgent` with `deliverAgentCommandResult` — that second step is +what actually reads `sessionEntry.deliveryContext` and invokes the channel +plugin's send. `runEmbeddedAgent` alone would produce a transcript reply +but never push it outbound. No second LLM call, no plugin-side IM client. + +If neither plugin-level nor per-sensor deliver is set, OR the OpenClaw +runtime predates `runtime.subagent.run`, the plugin falls back to plain +`runEmbeddedAgent` (the reply stays in the W2A session lane). If the named +channel plugin isn't loaded, the run still completes but OpenClaw's +outbound resolver refuses to send — check `openclaw plugins list --json` +to confirm the channel id matches an enabled plugin. + ## ContextInjection Prerequisite This plugin refuses to start unless `agents.defaults.contextInjection` is exactly `"continuation-skip"`. diff --git a/openclaw-plugin/openclaw.plugin.json b/openclaw-plugin/openclaw.plugin.json index 6b0054b..91091f5 100644 --- a/openclaw-plugin/openclaw.plugin.json +++ b/openclaw-plugin/openclaw.plugin.json @@ -60,6 +60,30 @@ "minimum": 1000, "default": 3600000, "description": "How long X-Request-ID dedup entries stay in memory." + }, + "deliver": { + "type": "object", + "additionalProperties": false, + "description": "Default delivery target for sensor-driven agent replies. When set, OpenClaw routes the assistant reply through the named channel plugin (e.g. feishu, lark, whatsapp, telegram) to the recipient. Per-sensor manifest entries can override this. Leave unset to keep replies inside the W2A session lane only.", + "required": ["channel", "to"], + "properties": { + "channel": { + "type": "string", + "description": "Channel id matching an enabled OpenClaw channel plugin (run `openclaw plugins list --json` and pick a plugin that exposes a non-empty channelIds array)." + }, + "to": { + "type": "string", + "description": "Recipient identifier on the channel: chat id, user id, or platform-specific target." + }, + "accountId": { + "type": "string", + "description": "Optional account id for multi-account channels." + }, + "threadId": { + "type": ["string", "number"], + "description": "Optional thread/topic id for routing inside a thread." + } + } } } } diff --git a/openclaw-plugin/skills/world2agent-manage/SKILL.md b/openclaw-plugin/skills/world2agent-manage/SKILL.md index 8873181..71971e4 100644 --- a/openclaw-plugin/skills/world2agent-manage/SKILL.md +++ b/openclaw-plugin/skills/world2agent-manage/SKILL.md @@ -150,6 +150,44 @@ Optional flags: multiple instances of the same sensor) - `--isolated` to run the sensor out-of-process (for unstable third-party sensors) +- `--deliver-channel --deliver-to ` to push the agent's reply to a + chat platform (see step 6b). + +### Step 6b — Offer to push replies to a chat platform + +Check whether the user has any inbound chat-platform plugin enabled: + +```bash +openclaw plugins list --json | jq -r \ + '.plugins[] | select(.enabled == true) | select(.channelIds | length > 0) | .id' +``` + +If the output is **empty**, skip this step — there's no IM to push to. The +sensor will still run; replies stay in the OpenClaw session lane (visible via +`openclaw sessions --agent main` and the dashboard). + +If one or more channels are listed (e.g. `feishu`, `lark`, `whatsapp`, +`telegram`, `discord`, `slack`, `signal`, `imessage`, `line`, `msteams`), +ask the user once — in their language — whether they want this sensor's +replies pushed to one of those chats. If yes, also ask for the recipient +target id (chat id, user id, or platform-specific target — the user knows +this from when they paired the channel; never invent it). + +Append to the install command: + +```bash + --deliver-channel \ + --deliver-to +``` + +Optional: `--deliver-account ` for multi-account channels, +`--deliver-thread ` to post into a specific thread/topic. + +The user can also set this once globally under +`plugins.world2agent.deliver` in `~/.openclaw/openclaw.json`; per-sensor +flags override that default. If the user wants the same target for +everything they're about to install, suggest they set the global default +instead of repeating the flags every time. ### Step 7 — Confirm and tell the user how to activate diff --git a/openclaw-plugin/src/cli.ts b/openclaw-plugin/src/cli.ts index 451bac3..099a475 100644 --- a/openclaw-plugin/src/cli.ts +++ b/openclaw-plugin/src/cli.ts @@ -3,6 +3,7 @@ import { packageToSkillId } from "@world2agent/sdk"; import { assertContextInjectionCompatible, loadEffectiveOpenClawConfig, + normalizeDeliver, upsertDedicatedAgentSkillAllowlist, } from "./config.js"; import { ensurePackageInstalled, loadConfigFile, maybeUninstallPackage, runCommand, writeGeneratedSkill } from "./install.js"; @@ -56,6 +57,16 @@ export function registerWorld2AgentCli(services: World2AgentCliServices): void { "--skip-generate-skill", "Do not auto-generate a fallback SKILL.md. Use this when the calling agent has already written a personalized handler skill via the SETUP.md Q&A flow.", ) + .option( + "--deliver-channel ", + "Channel id to push assistant replies through (e.g. feishu, lark, whatsapp, telegram). Must match an enabled OpenClaw channel plugin.", + ) + .option( + "--deliver-to ", + "Recipient on the deliver channel (chat id, user id, or platform-specific target). Required when --deliver-channel is set.", + ) + .option("--deliver-account ", "Optional account id for multi-account channels") + .option("--deliver-thread ", "Optional thread/topic id for routing replies inside a thread") .action(async (pkg: string, options: Record) => { printJson(await runAddCommand(services, pkg, options)); }); @@ -117,6 +128,23 @@ async function runAddCommand( const skillId = packageToSkillId(pkg); const sensorConfig = await loadConfigFile(configFile, configJson, installed); + const deliverChannel = optionString(options, "deliverChannel"); + const deliverTo = optionString(options, "deliverTo"); + if ((deliverChannel && !deliverTo) || (deliverTo && !deliverChannel)) { + throw new Error( + "--deliver-channel and --deliver-to must be set together (channel without recipient — or vice versa — has no routing target).", + ); + } + const deliver = + deliverChannel && deliverTo + ? normalizeDeliver({ + channel: deliverChannel, + to: deliverTo, + accountId: optionString(options, "deliverAccount"), + threadId: optionString(options, "deliverThread"), + }) + : undefined; + // The agent-driven path (world2agent-manage skill running SETUP.md Q&A) // writes a personalized SKILL.md before invoking this command and passes // --skip-generate-skill. The fallback path (direct CLI use) lets the @@ -135,6 +163,7 @@ async function runAddCommand( enabled: true, isolated, config: sensorConfig, + ...(deliver ? { deliver } : {}), }; await writeManifest(services.paths, upsertSensorEntry(manifest, entry)); @@ -153,6 +182,7 @@ async function runAddCommand( isolated, skill_generated: skillGenerated.written, skill_path: join(services.paths.openclawSkillsDir, skillId, "SKILL.md"), + deliver: deliver ?? null, allowlist, reload, }; diff --git a/openclaw-plugin/src/config.ts b/openclaw-plugin/src/config.ts index 655d2a0..3ca4539 100644 --- a/openclaw-plugin/src/config.ts +++ b/openclaw-plugin/src/config.ts @@ -4,7 +4,7 @@ import type { OpenClawPluginApi, OpenClawPluginConfig, } from "./openclaw/plugin-sdk/types.js"; -import type { RequiredWorld2AgentPluginConfig } from "./types.js"; +import type { DeliverConfig, RequiredWorld2AgentPluginConfig } from "./types.js"; export const REQUIRED_CONTEXT_INJECTION = "continuation-skip"; @@ -59,6 +59,31 @@ export function normalizePluginConfig( requestTimeoutMs: asPositiveInteger(raw.requestTimeoutMs) ?? 120_000, ingestHmacSecretFile: asOptionalString(raw.ingestHmacSecretFile), ingestDedupTtlMs: asPositiveInteger(raw.ingestDedupTtlMs) ?? 3_600_000, + deliver: normalizeDeliver((raw as Record).deliver), + }; +} + +export function normalizeDeliver(value: unknown): DeliverConfig | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const raw = value as Record; + const channel = asOptionalString(raw.channel); + const to = asOptionalString(raw.to); + // channel + to are both required to actually route a reply: lastChannel + // alone wouldn't tell OpenClaw who to send to. Bail silently otherwise so + // a half-filled config doesn't break dispatch. + if (!channel || !to) return undefined; + + const accountId = asOptionalString(raw.accountId); + const threadId = + typeof raw.threadId === "number" && Number.isFinite(raw.threadId) + ? Math.trunc(raw.threadId) + : asOptionalString(raw.threadId); + + return { + channel, + to, + ...(accountId !== undefined ? { accountId } : {}), + ...(threadId !== undefined ? { threadId } : {}), }; } diff --git a/openclaw-plugin/src/dispatch.ts b/openclaw-plugin/src/dispatch.ts index ec0700f..7599914 100644 --- a/openclaw-plugin/src/dispatch.ts +++ b/openclaw-plugin/src/dispatch.ts @@ -9,6 +9,7 @@ import type { OpenClawPluginApi, } from "./openclaw/plugin-sdk/types.js"; import type { + DeliverConfig, Dispatcher, DispatcherDispatchInput, EmbeddedDispatcherOptions, @@ -91,6 +92,11 @@ export class EmbeddedDispatcher implements Dispatcher { this.options.pluginConfig, ); + // Per-sensor deliver overrides plugin default. Both are optional — when + // neither is set, the session entry stays untargeted and OpenClaw keeps + // the reply inside the W2A session lane (current behavior). + const deliver = input.deliver ?? this.options.pluginConfig.deliver; + // ───────────────────────────────────────────────────────────────── // Dispatch via runEmbeddedAgent + `# System Event` prompt prefix. // @@ -109,19 +115,23 @@ export class EmbeddedDispatcher implements Dispatcher { // agent treats the `# System Event` block as an external notification // thanks to the framing, even though it lives in user-role position. // ───────────────────────────────────────────────────────────────── - if (typeof runtimeAgent?.runEmbeddedAgent !== "function") { + const runtimeSubagent = api.runtime?.subagent; + const canDeliverViaSubagent = + Boolean(deliver) && typeof runtimeSubagent?.run === "function"; + + if (!canDeliverViaSubagent && typeof runtimeAgent?.runEmbeddedAgent !== "function") { throw new Error( - "OpenClaw runtime does not expose runEmbeddedAgent — this plugin cannot dispatch signals against this OpenClaw version.", + "OpenClaw runtime exposes neither runtime.subagent.run nor runtime.agent.runEmbeddedAgent — this plugin cannot dispatch signals against this OpenClaw version.", ); } const workspaceDir = this.options.pluginConfig.workspaceDir ?? - tryCall(() => runtimeAgent.resolveAgentWorkspaceDir?.(config, agentId)) ?? + tryCall(() => runtimeAgent?.resolveAgentWorkspaceDir?.(config, agentId)) ?? join(openclawHome, "workspace"); const timeoutMs = this.options.pluginConfig.requestTimeoutMs ?? - tryCall(() => runtimeAgent.resolveAgentTimeoutMs?.(config)) ?? + tryCall(() => runtimeAgent?.resolveAgentTimeoutMs?.(config)) ?? 120_000; await ensureSessionRegistered({ @@ -133,6 +143,7 @@ export class EmbeddedDispatcher implements Dispatcher { sensorId: input.sensorId, provider, model, + deliver, }); const promptForTurn = @@ -145,20 +156,74 @@ export class EmbeddedDispatcher implements Dispatcher { "---\n\n" + signalText; - const result = await runtimeAgent.runEmbeddedAgent!({ - sessionId, - sessionKey, - agentId, - runId: randomUUID(), - sessionFile, - workspaceDir, - agentDir, - config, - prompt: promptForTurn, - timeoutMs, - ...(provider ? { provider } : {}), - ...(model ? { model } : {}), - } as Parameters>[0]); + // ───────────────────────────────────────────────────────────────── + // Delivery path selection. + // + // When deliver is configured AND OpenClaw exposes runtime.subagent.run, + // use the high-level subagent path: it wraps runEmbeddedAgent and ALSO + // calls deliverAgentCommandResult after the run, which is what actually + // pushes the assistant reply to the channel plugin (feishu/lark/...). + // + // runEmbeddedAgent alone does NOT deliver — it just produces an + // assistant message in the session transcript. Only deliverAgentCommandResult + // reads sessionEntry.deliveryContext / messageChannel and invokes + // channel.send. We wrote deliveryContext to the session entry above, + // but that's load-bearing only when something downstream reads it. + // + // Fallback path (no subagent / no deliver): keep the original + // runEmbeddedAgent call so behavior is unchanged for users who haven't + // configured deliver — and to support OpenClaw versions that predate + // the subagent runtime. + // ───────────────────────────────────────────────────────────────── + let result: unknown; + if (canDeliverViaSubagent) { + // Don't pass provider/model — runtime.subagent.run rejects per-call + // overrides from plugins ("provider/model override is not authorized + // for this plugin subagent run."). Let OpenClaw resolve from agent + // defaults (resolved upstream and persisted on the session entry). + const { runId } = await runtimeSubagent!.run!({ + sessionKey, + message: promptForTurn, + deliver: true, + }); + // Wait so the dispatcher's per-sensor queue stays meaningful (next + // signal for the same sensor doesn't kick off until this reply has + // been delivered). If waitForRun isn't available, fall through with + // just the runId — fire-and-forget. + if (typeof runtimeSubagent!.waitForRun === "function") { + const wait = await runtimeSubagent!.waitForRun({ runId, timeoutMs }); + result = { runId, wait }; + } else { + result = { runId }; + } + } else { + result = await runtimeAgent!.runEmbeddedAgent!({ + sessionId, + sessionKey, + agentId, + runId: randomUUID(), + sessionFile, + workspaceDir, + agentDir, + config, + prompt: promptForTurn, + timeoutMs, + ...(provider ? { provider } : {}), + ...(model ? { model } : {}), + // Pass turn-source delivery hints. Only useful in old runtimes that + // already auto-deliver from runEmbeddedAgent — modern runtimes need + // the subagent.run path above for actual outbound. Kept for forward + // compat / future runtime versions that wire this through. + ...(deliver + ? { + messageChannel: deliver.channel, + messageTo: deliver.to, + ...(deliver.threadId !== undefined ? { messageThreadId: deliver.threadId } : {}), + ...(deliver.accountId ? { agentAccountId: deliver.accountId } : {}), + } + : {}), + } as Parameters["runEmbeddedAgent"]>>[0]); + } await mirrorIsolatedSessionFiles(agentDir, sessionId).catch(() => undefined); await ensureSessionRegistered({ @@ -170,9 +235,14 @@ export class EmbeddedDispatcher implements Dispatcher { sensorId: input.sensorId, provider, model, + deliver, }).catch(() => undefined); - return { ok: true, path: "embedded", result }; + return { + ok: true, + path: canDeliverViaSubagent ? "subagent" : "embedded", + result, + }; } } @@ -185,14 +255,50 @@ async function ensureSessionRegistered(params: { sensorId: string; provider?: string; model?: string; + deliver?: DeliverConfig; }): Promise { const now = Date.now(); - - // Build the entry once, used by both paths below. - const entryFor = (existing?: Record): Record => - existing - ? { ...existing, updatedAt: now, lastInteractionAt: now } - : { + const deliver = params.deliver; + + // When deliver is configured, mark the session with channel + recipient so + // OpenClaw's resolveAgentDeliveryPlan picks it up and routes the assistant + // reply through the corresponding channel plugin (lark/feishu/whatsapp/...). + // Without this, lastChannel="world2agent" keeps the reply inside the W2A + // session lane only. + const deliverFields = deliver + ? { + lastChannel: deliver.channel, + lastTo: deliver.to, + ...(deliver.accountId ? { lastAccountId: deliver.accountId } : {}), + ...(deliver.threadId !== undefined ? { lastThreadId: deliver.threadId } : {}), + deliveryContext: { + channel: deliver.channel, + to: deliver.to, + ...(deliver.accountId ? { accountId: deliver.accountId } : {}), + ...(deliver.threadId !== undefined ? { threadId: deliver.threadId } : {}), + }, + } + : { lastChannel: "world2agent" }; + + // Build the entry once, used by both paths below. On existing entries we + // also re-assert deliverFields so a config change (e.g. user re-paired + // their channel) takes effect on the very next signal without needing a + // session reset. We also strip a stale `agentHarnessId` because earlier + // versions of this plugin pinned `agentHarnessId: "claude-cli"` and that + // value, once persisted, makes OpenClaw refuse the run on hosts that + // don't have that harness registered. + const entryFor = (existing?: Record): Record => { + if (existing) { + const merged: Record = { + ...existing, + ...deliverFields, + updatedAt: now, + lastInteractionAt: now, + }; + delete merged.agentHarnessId; + return merged; + } + return { sessionId: params.sessionId, sessionFile: params.sessionFile, sessionStartedAt: now, @@ -203,8 +309,12 @@ async function ensureSessionRegistered(params: { status: "idle", origin: "world2agent", chatType: "embedded", - lastChannel: "world2agent", - agentHarnessId: "claude-cli", + ...deliverFields, + // No agentHarnessId — let OpenClaw resolve from agent defaults. + // Pinning a specific harness here used to break setups that don't + // have that harness registered (e.g. claude-cli unavailable on a + // host that runs openrouter/auto). Once written into the session + // entry, OpenClaw refuses to switch harness for the session id. ...(params.model ? { model: params.model } : {}), ...(params.provider ? { modelProvider: params.provider } : {}), totalTokens: 0, @@ -215,6 +325,7 @@ async function ensureSessionRegistered(params: { contextTokens: 0, runtimeMs: 0, }; + }; // Try OpenClaw's session-store API first (preferred — it integrates with // OpenClaw's in-memory caches). Best-effort; if it silently no-ops or @@ -354,6 +465,7 @@ export class HttpDispatcher { sensorId: payload.sensor_id, skillId: payload.skill_id, signal: payload.signal, + ...(payload.deliver ? { deliver: payload.deliver } : {}), }); writeJson(res, 202, { ok: true }); diff --git a/openclaw-plugin/src/manifest.ts b/openclaw-plugin/src/manifest.ts index 7ae585b..6990272 100644 --- a/openclaw-plugin/src/manifest.ts +++ b/openclaw-plugin/src/manifest.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { access, readFile, rm } from "node:fs/promises"; import { packageToSkillId } from "@world2agent/sdk"; +import { normalizeDeliver } from "./config.js"; import { ensureWorld2AgentDirs, writeTextAtomic } from "./paths.js"; import type { SensorEntry, SensorManifest, World2AgentPaths } from "./types.js"; @@ -65,6 +66,7 @@ export function removeSensorEntry( } export function normalizeSensorEntry(entry: SensorEntry): SensorEntry { + const deliver = normalizeDeliver(entry.deliver); return { sensor_id: entry.sensor_id, pkg: entry.pkg, @@ -72,6 +74,7 @@ export function normalizeSensorEntry(entry: SensorEntry): SensorEntry { enabled: entry.enabled !== false, isolated: entry.isolated === true, config: entry.config ?? {}, + ...(deliver ? { deliver } : {}), }; } @@ -146,6 +149,7 @@ function parseSensorEntry(raw: unknown, index: number): SensorEntry { throw new Error(`sensor[${index}].config must be an object`); } + const deliver = normalizeDeliver(entry.deliver); return { sensor_id: sensorId, pkg, @@ -156,6 +160,7 @@ function parseSensorEntry(raw: unknown, index: number): SensorEntry { enabled, isolated, config: config as Record, + ...(deliver ? { deliver } : {}), }; } diff --git a/openclaw-plugin/src/openclaw/plugin-sdk/types.ts b/openclaw-plugin/src/openclaw/plugin-sdk/types.ts index 473a0b0..72bcc9f 100644 --- a/openclaw-plugin/src/openclaw/plugin-sdk/types.ts +++ b/openclaw-plugin/src/openclaw/plugin-sdk/types.ts @@ -116,6 +116,44 @@ export interface OpenClawRuntimeAgentApi { session?: OpenClawRuntimeAgentSessionApi; } +export interface OpenClawSubagentRunParams { + sessionKey: string; + message: string; + provider?: string; + model?: string; + /** + * When true, OpenClaw runs the embedded agent AND calls + * deliverAgentCommandResult — the assistant reply is routed to the + * channel/recipient stored on the session entry's deliveryContext. + * Without `deliver: true`, the run produces an assistant message that + * stays inside the session lane (no IM push). + */ + deliver?: boolean; + extraSystemPrompt?: string; + lane?: string; + lightContext?: boolean; + idempotencyKey?: string; +} + +export interface OpenClawSubagentRunResult { + runId: string; +} + +export interface OpenClawSubagentWaitParams { + runId: string; + timeoutMs?: number; +} + +export interface OpenClawSubagentWaitResult { + status: "ok" | "error" | "timeout"; + error?: string; +} + +export interface OpenClawRuntimeSubagentApi { + run?(params: OpenClawSubagentRunParams): Promise; + waitForRun?(params: OpenClawSubagentWaitParams): Promise; +} + export interface OpenClawConfigWriteOptions { afterWrite?: { mode?: "auto" | "skip" | "refresh" } & Record; [key: string]: unknown; @@ -204,6 +242,7 @@ export interface OpenClawPluginApi { agent?: OpenClawRuntimeAgentApi; config?: OpenClawRuntimeConfigApi; system?: OpenClawRuntimeSystemApi; + subagent?: OpenClawRuntimeSubagentApi; }; } diff --git a/openclaw-plugin/src/runtime.ts b/openclaw-plugin/src/runtime.ts index fe9368e..cd76fe4 100644 --- a/openclaw-plugin/src/runtime.ts +++ b/openclaw-plugin/src/runtime.ts @@ -113,6 +113,7 @@ export class SensorRuntime { sensorId: entry.sensor_id, skillId: entry.skill_id, signal, + ...(entry.deliver ? { deliver: entry.deliver } : {}), }); this.log( `[w2a/${entry.sensor_id}] dispatched ${signal.signal_id} [${signal.event?.type ?? "unknown"}]`, diff --git a/openclaw-plugin/src/types.ts b/openclaw-plugin/src/types.ts index ec8a10b..ec04833 100644 --- a/openclaw-plugin/src/types.ts +++ b/openclaw-plugin/src/types.ts @@ -15,6 +15,13 @@ export interface World2AgentPaths { ingestHmacSecretFile: string; } +export interface DeliverConfig { + channel: string; + to: string; + accountId?: string; + threadId?: string | number; +} + export interface SensorEntry { sensor_id: string; pkg: string; @@ -22,6 +29,7 @@ export interface SensorEntry { enabled: boolean; isolated?: boolean; config: Record; + deliver?: DeliverConfig; } export interface SensorManifest { @@ -34,6 +42,7 @@ export interface DispatcherDispatchInput { skillId: string; signal: W2ASignal; sessionId?: string; + deliver?: DeliverConfig; } export interface Dispatcher { @@ -51,6 +60,7 @@ export interface HttpIngestEnvelope { sensor_id: string; skill_id: string; signal: W2ASignal; + deliver?: DeliverConfig; } export interface HttpDispatcherOptions { @@ -89,6 +99,7 @@ export interface RequiredWorld2AgentPluginConfig { requestTimeoutMs: number; ingestHmacSecretFile?: string; ingestDedupTtlMs: number; + deliver?: DeliverConfig; } export interface IsolatedRunnerHandle { diff --git a/openclaw-plugin/test/dispatch.test.ts b/openclaw-plugin/test/dispatch.test.ts index 2c11083..f371f62 100644 --- a/openclaw-plugin/test/dispatch.test.ts +++ b/openclaw-plugin/test/dispatch.test.ts @@ -67,6 +67,182 @@ describe("EmbeddedDispatcher", () => { expect(calls[0]?.prompt.startsWith("# System Event")).toBe(true); expect(calls[0]?.prompt).toContain("Use skill: world2agent-sensor-fake-tick"); }); + + it("propagates deliver config to runEmbeddedAgent and persists it on the session entry", async () => { + const calls: EmbeddedAgentRunRequest[] = []; + const root = `/tmp/w2a-openclaw-dispatch-deliver-${Date.now()}`; + const dispatcher = new EmbeddedDispatcher({ + api: { + runtime: { + agent: { + runEmbeddedAgent: vi.fn(async (request: EmbeddedAgentRunRequest) => { + calls.push(request); + return { ok: true }; + }), + }, + }, + }, + openclawConfigRef: { + current: { + agents: { + defaults: { contextInjection: "continuation-skip" }, + list: [], + }, + }, + }, + pluginConfig: { + defaultAgentId: "world2agent", + requestTimeoutMs: 12_345, + ingestDedupTtlMs: 3_600_000, + deliver: { + channel: "feishu", + to: "oc_chat_abc123", + accountId: "acct-1", + }, + }, + paths: makePaths(root), + }); + + await dispatcher.dispatch({ + sensorId: "fake-tick", + skillId: "world2agent-sensor-fake-tick", + signal: TEST_SIGNAL, + }); + + expect(calls).toHaveLength(1); + const req = calls[0] as Record; + expect(req.messageChannel).toBe("feishu"); + expect(req.messageTo).toBe("oc_chat_abc123"); + expect(req.agentAccountId).toBe("acct-1"); + + const fs = await import("node:fs/promises"); + const storePath = `${root}/.openclaw/agents/world2agent/sessions/sessions.json`; + const raw = JSON.parse(await fs.readFile(storePath, "utf8")) as Record; + const entry = raw["agent:world2agent:w2a-fake-tick"] as Record; + expect(entry.lastChannel).toBe("feishu"); + expect(entry.lastTo).toBe("oc_chat_abc123"); + expect(entry.lastAccountId).toBe("acct-1"); + expect(entry.deliveryContext).toMatchObject({ + channel: "feishu", + to: "oc_chat_abc123", + accountId: "acct-1", + }); + }); + + it("uses runtime.subagent.run with deliver:true when deliver is configured AND subagent is exposed", async () => { + const subagentCalls: Array> = []; + const waitCalls: Array> = []; + const runEmbedded = vi.fn(async () => ({ ok: true })); + const root = `/tmp/w2a-openclaw-dispatch-subagent-${Date.now()}`; + const dispatcher = new EmbeddedDispatcher({ + api: { + runtime: { + agent: { + runEmbeddedAgent: runEmbedded, + }, + subagent: { + run: vi.fn(async (params: Record) => { + subagentCalls.push(params); + return { runId: "run-abc" }; + }), + waitForRun: vi.fn(async (params: Record) => { + waitCalls.push(params); + return { status: "ok" }; + }), + }, + }, + }, + openclawConfigRef: { + current: { + agents: { + defaults: { contextInjection: "continuation-skip" }, + list: [], + }, + }, + }, + pluginConfig: { + defaultAgentId: "world2agent", + requestTimeoutMs: 12_345, + ingestDedupTtlMs: 3_600_000, + deliver: { channel: "feishu", to: "oc_chat_xxx" }, + }, + paths: makePaths(root), + }); + + const result = (await dispatcher.dispatch({ + sensorId: "fake-tick", + skillId: "world2agent-sensor-fake-tick", + signal: TEST_SIGNAL, + })) as { path: string; result: { runId: string } }; + + expect(result.path).toBe("subagent"); + expect(result.result.runId).toBe("run-abc"); + expect(subagentCalls).toHaveLength(1); + expect(subagentCalls[0]?.sessionKey).toBe("agent:world2agent:w2a-fake-tick"); + expect(subagentCalls[0]?.deliver).toBe(true); + expect((subagentCalls[0]?.message as string).startsWith("# System Event")).toBe(true); + // Per-call provider/model overrides are rejected by OpenClaw's plugin + // subagent runtime — we MUST NOT pass them; the runtime resolves the + // provider/model from agent defaults instead. + expect(subagentCalls[0]).not.toHaveProperty("provider"); + expect(subagentCalls[0]).not.toHaveProperty("model"); + expect(waitCalls).toHaveLength(1); + expect(waitCalls[0]?.runId).toBe("run-abc"); + // runEmbeddedAgent must NOT be invoked when subagent path is taken + expect(runEmbedded).not.toHaveBeenCalled(); + + // Session entry still gets deliveryContext written so the runtime can + // resolve the channel target inside subagent.run. + const fs = await import("node:fs/promises"); + const storePath = `${root}/.openclaw/agents/world2agent/sessions/sessions.json`; + const raw = JSON.parse(await fs.readFile(storePath, "utf8")) as Record; + const entry = raw["agent:world2agent:w2a-fake-tick"] as Record; + expect(entry.lastChannel).toBe("feishu"); + expect(entry.lastTo).toBe("oc_chat_xxx"); + }); + + it("per-sensor deliver in dispatch input overrides plugin default", async () => { + const calls: EmbeddedAgentRunRequest[] = []; + const dispatcher = new EmbeddedDispatcher({ + api: { + runtime: { + agent: { + runEmbeddedAgent: vi.fn(async (request: EmbeddedAgentRunRequest) => { + calls.push(request); + return { ok: true }; + }), + }, + }, + }, + openclawConfigRef: { + current: { + agents: { + defaults: { contextInjection: "continuation-skip" }, + list: [], + }, + }, + }, + pluginConfig: { + defaultAgentId: "world2agent", + requestTimeoutMs: 12_345, + ingestDedupTtlMs: 3_600_000, + deliver: { channel: "feishu", to: "default-chat" }, + }, + paths: makePaths(`/tmp/w2a-openclaw-dispatch-override-${Date.now()}`), + }); + + await dispatcher.dispatch({ + sensorId: "fake-tick", + skillId: "world2agent-sensor-fake-tick", + signal: TEST_SIGNAL, + deliver: { channel: "telegram", to: "user-42" }, + }); + + expect(calls).toHaveLength(1); + const req = calls[0] as Record; + expect(req.messageChannel).toBe("telegram"); + expect(req.messageTo).toBe("user-42"); + }); }); describe("HttpDispatcher", () => { From 94949c3f48077c48ebc6f847f29f097d21a31fbe Mon Sep 17 00:00:00 2001 From: Nova-machinepulse Date: Wed, 29 Apr 2026 21:06:12 +0800 Subject: [PATCH 6/9] fix(openclaw-plugin): reduce duplicate sensor startups --- openclaw-plugin/README.md | 62 ++++++++----- openclaw-plugin/src/dispatch.ts | 16 ++-- openclaw-plugin/src/plugin.ts | 24 +++++ openclaw-plugin/src/runtime.ts | 29 +++++++ openclaw-plugin/test/runtime.test.ts | 125 +++++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 32 deletions(-) create mode 100644 openclaw-plugin/test/runtime.test.ts diff --git a/openclaw-plugin/README.md b/openclaw-plugin/README.md index 1004773..3972c6e 100644 --- a/openclaw-plugin/README.md +++ b/openclaw-plugin/README.md @@ -1,10 +1,12 @@ # @world2agent/openclaw-plugin -Native OpenClaw plugin for running World2Agent sensors and dispatching their signals into OpenClaw agent turns as **system events** (not user messages). +Native OpenClaw plugin for running World2Agent sensors and dispatching their signals into dedicated agent session lanes — **never into your main chat session**. -The default path is in-process: enabled sensors are imported directly inside the plugin process, and each emitted signal is enqueued as a system event for a dedicated agent. OpenClaw drains queued system events at the start of the next agent turn and prepends them to the prompt as `System:` lines — matching the semantics `claude-code-channel` uses with MCP `notifications/claude/channel`. +The default path is in-process: enabled sensors are imported directly inside the plugin process. Each emitted signal is wrapped in a `# System Event` markdown frame and dispatched via OpenClaw's `runtime.subagent.run` (or `runtime.agent.runEmbeddedAgent` for older runtimes). The framed signal lives in user-role position within a per-sensor lane keyed `agent::w2a-`, but the framing makes the agent treat it as an external notification rather than a user message. -`isolated: true` is opt-in and reuses the Hermes bridge runner/supervisor patterns for subprocess execution plus plugin-local HTTP ingest. +When [`deliver`](#pushing-replies-to-a-chat-platform-lark--whatsapp--telegram--) is configured, the same path also pushes the assistant reply back to a chat platform (lark / feishu / whatsapp / telegram / …) via the `subagent.run({ deliver: true })` flag — OpenClaw resolves the channel target from the session entry's `deliveryContext` we wrote. + +`isolated: true` is opt-in and reuses the Hermes bridge runner/supervisor patterns for subprocess execution plus plugin-local HTTP ingest. (See [caveat](#pushing-replies-to-a-chat-platform-lark--whatsapp--telegram--) — `deliver` currently does not work for isolated sensors.) ## Install @@ -63,7 +65,7 @@ openclaw world2agent --help # → Commands: reload, sensor ``` -### 4. Subscribe to your first source — by talking to OpenClaw +### 3. Subscribe to your first source — by talking to OpenClaw > ℹ️ By default W2A signals lane through your **existing `main` agent** but on a different sessionKey (one per sensor), so they don't pollute your normal chat session. If you'd rather route them to a dedicated agent for full isolation, set `defaultAgentId: "world2agent"` (or any other id) in this plugin's config and `openclaw agents add ` first. @@ -83,10 +85,15 @@ The plugin ships a `world2agent-manage` skill that activates on this kind of int 2. Ask you 1–3 questions defined in that file (poll thresholds, your topics of interest, reply depth) 3. Fill the SKILL.md template with your answers and write it to `~/.openclaw/skills/world2agent-sensor-hackernews/SKILL.md` 4. Run `openclaw world2agent sensor add ... --skip-generate-skill` to register -5. Ask **you** to run `openclaw gateway restart` in your terminal (the agent - intentionally does NOT run this itself — restarting the gateway from - inside the chat would kill this very chat session mid-reply) -6. Tell you when the first signal will arrive **and which session lane to +5. Run `openclaw world2agent reload` so the running plugin picks up the new + sensor (this is the normal path — adding a sensor only mutates + `~/.world2agent/sensors.json`, the plugin's own config in + `~/.openclaw/openclaw.json` is untouched) +6. If reload returns `ok: false` (e.g. the gateway-call socket times out), + ask **you** to run `openclaw gateway restart` in **your own terminal** + as a fallback. The agent never runs `gateway restart` itself — + restarting from inside the chat would terminate the reply mid-sentence +7. Tell you when the first signal will arrive **and which session lane to open in dashboard** to see the agent's replies (signals route to a separate `w2a-` session, not your main chat — see ["Where to view signal-driven agent runs"](#where-to-view-signal-driven-agent-runs) below) @@ -113,16 +120,19 @@ openclaw world2agent reload > ⚠️ **Run the restart in your own terminal — never inside an OpenClaw chat session.** `openclaw gateway restart` kills the gateway process, which terminates any in-flight chat reply mid-sentence. The conversational install path explicitly hands the restart back to the user for this reason. -Within ~60 seconds of the restart, the sensor will start polling. Each emitted signal triggers an agent turn under sessionKey `agent:main:w2a-`, with the signal framed as a `# System Event` block. +Within seconds of the reload (or restart), the sensor's first poll fires; subsequent polls follow the sensor's `interval_seconds` (e.g. 300 for `@world2agent/sensor-hackernews`). Each emitted signal triggers an agent turn under sessionKey `agent:main:w2a-`, with the signal framed as a `# System Event` block. ## Where to view signal-driven agent runs Each sensor gets its own session lane, separate from your main chat. The lane is keyed `agent::w2a-` (e.g. `agent:main:w2a-hackernews`), with stable session id `w2a-`. The -plugin dispatches signals via `runEmbeddedAgent` with a `# System Event` -markdown frame in the prompt, so the signal lives in user-role position -within the W2A session — but **never in your main chat session**. +plugin dispatches signals via OpenClaw's embedded-agent runtime +(`runtime.subagent.run` when `deliver` is configured, otherwise +`runtime.agent.runEmbeddedAgent`) with a `# System Event` markdown frame +in the prompt, so the signal lives in user-role position within the W2A +session lane — but **never in your main chat session** (`agent:main:main` +is untouched). Concrete: if you ran the conversational install for Hacker News, look for the `w2a-hackernews` session, not `main`. Your `main` chat is untouched. @@ -207,12 +217,20 @@ what actually reads `sessionEntry.deliveryContext` and invokes the channel plugin's send. `runEmbeddedAgent` alone would produce a transcript reply but never push it outbound. No second LLM call, no plugin-side IM client. -If neither plugin-level nor per-sensor deliver is set, OR the OpenClaw -runtime predates `runtime.subagent.run`, the plugin falls back to plain -`runEmbeddedAgent` (the reply stays in the W2A session lane). If the named -channel plugin isn't loaded, the run still completes but OpenClaw's -outbound resolver refuses to send — check `openclaw plugins list --json` -to confirm the channel id matches an enabled plugin. +If `deliver` is not set, the reply stays in the W2A session lane (visible +in the dashboard, no IM push). If the named channel plugin isn't loaded, +the run still completes but OpenClaw's outbound resolver refuses to send +— check `openclaw plugins list --json` to confirm the channel id matches +an enabled plugin. + +> ⚠️ **Known limitation — `deliver` does not currently apply to +> `isolated: true` sensors.** Subprocess sensors route signals through +> the plugin-local `/w2a/ingest` HTTP route, and that handler's call to +> `runtime.subagent.run` is rejected with `missing scope: operator.write` +> (in-process plugin calls have the scope; HTTP-route plugin calls do +> not). Those runs fall back to plain `runEmbeddedAgent` and the reply +> stays in the session lane only. In-process sensors (the default) push +> to the channel correctly. ## ContextInjection Prerequisite @@ -222,14 +240,16 @@ That check also runs before `openclaw world2agent sensor add`. There is no warni ## Relation to `hermes-sensor-bridge` -`hermes-sensor-bridge` solved the same World2Agent runtime problem for Hermes with webhook subscriptions plus supervised subprocesses. This package keeps the same manifest shape and reuses the runner/supervisor mechanics for `isolated: true`, but the primary OpenClaw path is simpler: native plugin registration plus `enqueueSystemEvent(...)` + `runEmbeddedAgent(...)`. +`hermes-sensor-bridge` solved the same World2Agent runtime problem for Hermes with webhook subscriptions plus supervised subprocesses. This package keeps the same manifest shape and reuses the runner/supervisor mechanics for `isolated: true`, but the primary OpenClaw path is simpler: native plugin registration plus `runtime.subagent.run` (which internally pairs `runEmbeddedAgent` with OpenClaw's outbound delivery resolver) — no separate gateway, no HMAC ingest, no platform bootstrap. ## Troubleshooting -**Plugin install blocked by safety scanner**: that's the security warning about `child_process`. Use `--dangerously-force-unsafe-install` (see step 3). +**Plugin install blocked by safety scanner**: that's the security warning about `child_process`. Use `--dangerously-force-unsafe-install` (see [§ Install the plugin](#2-install-the-plugin)). **`openclaw world2agent --help` says "unknown command"**: gateway hasn't reloaded the plugin yet. Run `openclaw gateway restart`. -**Sensors run but `openclaw sessions --agent world2agent` is empty**: you skipped step 4 (`openclaw agents add world2agent`) or step 5's `openclaw world2agent reload`. Each sensor's `dispatch failed` will be logged in `/tmp/openclaw/openclaw-*.log` — grep for the sensor id. +**Sensors run but `openclaw sessions --agent main` shows no `w2a-` lane**: the plugin manifest reload may have timed out. Verify with `openclaw world2agent sensor list` that the sensor is in the manifest, and grep `/tmp/openclaw/openclaw-*.log` for `[w2a/]` to see emit / dispatch / dispatch failed lines. If you set `defaultAgentId: "world2agent"` in this plugin's config, replace `--agent main` with `--agent world2agent` and make sure that agent exists in `agents.list` (`openclaw agents add world2agent` once before reload). **Wizards or interactive commands hang on the same terminal as the gateway**: sensor logs go to a namespaced logger, but very early gateway boot output still goes to stdout. Run interactive commands (`openclaw agents add ...`) from a terminal that isn't tailing gateway logs. + +**Replies don't reach the configured chat platform (`deliver` set but nothing arrives)**: first confirm the relevant in-process emit produced a non-empty assistant reply (NO_REPLY / empty content correctly suppresses delivery). If the reply is non-empty but no message lands, check that `openclaw plugins list --json` shows the named channel as enabled, and that `~/.openclaw/agents//sessions/sessions.json` for your sensor's lane has `lastChannel` / `lastTo` / `deliveryContext` matching your config — the plugin writes those fields on every dispatch. If they're missing, the plugin loaded an older dist (before this feature) — rebuild and re-link. diff --git a/openclaw-plugin/src/dispatch.ts b/openclaw-plugin/src/dispatch.ts index 7599914..adc235f 100644 --- a/openclaw-plugin/src/dispatch.ts +++ b/openclaw-plugin/src/dispatch.ts @@ -17,22 +17,16 @@ import type { HttpIngestEnvelope, } from "./types.js"; -const RUN_EMBEDDED_AGENT_ERROR = - "OpenClaw runtime is missing api.runtime.agent.runEmbeddedAgent. Verify the " + - "plugin is running inside a live OpenClaw gateway."; - -export function assertEmbeddedAgentRuntime(options: EmbeddedDispatcherOptions): void { - if (typeof options.api.runtime?.agent?.runEmbeddedAgent !== "function") { - throw new Error(RUN_EMBEDDED_AGENT_ERROR); - } -} - export class EmbeddedDispatcher implements Dispatcher { private readonly options: EmbeddedDispatcherOptions; private readonly queues = new Map>(); constructor(options: EmbeddedDispatcherOptions) { - assertEmbeddedAgentRuntime(options); + // Don't validate runtime APIs at construction time — either + // runtime.subagent.run (preferred when deliver is configured) OR + // runtime.agent.runEmbeddedAgent (fallback) is enough. The actual + // check happens per-dispatch in `dispatchNow` because it depends on + // whether the caller asked for delivery. this.options = options; } diff --git a/openclaw-plugin/src/plugin.ts b/openclaw-plugin/src/plugin.ts index c4deb15..c038c9a 100644 --- a/openclaw-plugin/src/plugin.ts +++ b/openclaw-plugin/src/plugin.ts @@ -16,6 +16,16 @@ import { IsolatedRunnerManager } from "./isolated.js"; import { SensorRuntime } from "./runtime.js"; import { getWorld2AgentPaths } from "./paths.js"; +// Module-level state. OpenClaw can call register() multiple times within +// a single gateway process (e.g. config-driven plugin reload, or some +// startup sequences that load the plugin twice). Without this state, each +// register() would create a fresh SensorRuntime + start a fresh sensor, +// leaving the previous SensorRuntime's sensor running as an orphan. The +// orphan keeps its own setInterval poll loop and FileSensorStore mirror, +// causing duplicate emits and dedup chaos. Storing state at module scope +// lets a re-register stop the previous runtime before creating a new one. +let activeRuntime: SensorRuntime | null = null; + // register() MUST stay synchronous: OpenClaw's plugin loader logs // "plugin register returned a promise; async registration is ignored" // and drops every api.register* call that follows an await. @@ -41,6 +51,19 @@ export function createWorld2AgentPlugin() { assertContextInjectionCompatible(openclawConfig); const openclawConfigRef = { current: openclawConfig }; + // If a previous register() left a runtime running in this same + // process, stop its sensors before swapping in the new one. Fire and + // forget — register() must stay sync — but the stopAll runs to + // completion in the background and the old SensorRuntime is then + // garbage-collected once nothing references it. + const previousRuntime = activeRuntime; + if (previousRuntime) { + log(api, "[w2a] re-register detected; stopping previous runtime's sensors"); + void previousRuntime.stopAll().catch((err) => { + log(api, `[w2a] previous runtime stopAll failed: ${err instanceof Error ? err.message : String(err)}`); + }); + } + const embeddedDispatcher = new EmbeddedDispatcher({ api, openclawConfigRef, @@ -69,6 +92,7 @@ export function createWorld2AgentPlugin() { paths, log: (line) => log(api, line), }); + activeRuntime = runtime; api.registerGatewayMethod?.("world2agent.reload", async () => { const nextConfig = await loadEffectiveOpenClawConfig(api); diff --git a/openclaw-plugin/src/runtime.ts b/openclaw-plugin/src/runtime.ts index cd76fe4..646f482 100644 --- a/openclaw-plugin/src/runtime.ts +++ b/openclaw-plugin/src/runtime.ts @@ -18,6 +18,14 @@ export class SensorRuntime { private readonly paths: World2AgentPaths; private readonly log: (line: string) => void; private readonly handles = new Map(); + // Serialize applyManifest calls. Without this, two concurrent invocations + // (e.g. plugin startup race with `world2agent.reload`, or two reloads in + // quick succession) can both observe an empty handle map, both call + // `startHandle`, and orphan one of the resulting in-process sensor + // instances — the orphan keeps a private `setInterval` poll loop and a + // private `FileSensorStore` mirror, defeating dedup and creating an emit + // storm. Same pattern as hermes-sensor-bridge's supervisor. + private applyLock: Promise = Promise.resolve(); constructor(options: SensorRuntimeOptions) { this.dispatcher = options.dispatcher; @@ -27,6 +35,27 @@ export class SensorRuntime { } async applyManifest(entries: SensorEntry[]): Promise { + const callId = Math.random().toString(36).slice(2, 8); + let release!: () => void; + const previous = this.applyLock; + this.applyLock = new Promise((resolve) => { + release = resolve; + }); + this.log(`[w2a/lock] ${callId} queued (handles=${[...this.handles.keys()].join(",") || "none"})`); + await previous.catch(() => undefined); + this.log(`[w2a/lock] ${callId} acquired (handles=${[...this.handles.keys()].join(",") || "none"})`); + try { + const result = await this.applyManifestUnlocked(entries); + this.log( + `[w2a/lock] ${callId} done started=${result.started.length} restarted=${result.restarted.length} stopped=${result.stopped.length} failed=${result.failed.length} (handles=${[...this.handles.keys()].join(",") || "none"})`, + ); + return result; + } finally { + release(); + } + } + + private async applyManifestUnlocked(entries: SensorEntry[]): Promise { const desired = entries.filter((entry) => entry.enabled !== false); const result: ApplyResult = { started: [], diff --git a/openclaw-plugin/test/runtime.test.ts b/openclaw-plugin/test/runtime.test.ts new file mode 100644 index 0000000..7ed8329 --- /dev/null +++ b/openclaw-plugin/test/runtime.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from "vitest"; + +// Stub `startSensor` so SensorRuntime doesn't try to import the real +// hackernews sensor or hit the HN API. The mock returns a no-op cleanup. +const startSensorMock = vi.fn(); +vi.mock("@world2agent/sdk", () => ({ + startSensor: (...args: unknown[]) => startSensorMock(...args), + FileSensorStore: class { + constructor(_opts: unknown) {} + async flush() {} + }, +})); + +// Avoid the dynamic-import inside `loadSensorSpec` — we replace it via the +// resolveImportTarget shim. Easier: stub `runtime.ts`'s loadSensorSpec by +// providing a fake package that resolves cleanly. We do this by mocking +// `./supervisor/shared.js`'s `resolveImportTarget` to point at a tiny +// in-memory module. +vi.mock("../src/supervisor/shared.js", async () => { + const actual = await vi.importActual>( + "../src/supervisor/shared.js", + ); + return { + ...actual, + resolveImportTarget: () => "data:text/javascript;base64," + + Buffer.from("export default { start: () => () => undefined };").toString("base64"), + }; +}); + +import { SensorRuntime } from "../src/runtime.js"; +import type { Dispatcher, SensorEntry, World2AgentPaths } from "../src/types.js"; + +const PATHS: World2AgentPaths = { + baseDir: "/tmp/w2a-runtime-test", + manifestFile: "/tmp/w2a-runtime-test/sensors.json", + stateDir: "/tmp/w2a-runtime-test/state", + sessionDir: "/tmp/w2a-runtime-test/sessions", + openclawHome: "/tmp/w2a-runtime-test/.openclaw", + openclawSkillsDir: "/tmp/w2a-runtime-test/.openclaw/skills", + ingestHmacSecretFile: "/tmp/w2a-runtime-test/.secret", +}; + +const ISOLATED_NOOP = { + apply: vi.fn(async () => ({ started: [], restarted: [], stopped: [], failed: [] })), + terminateAll: vi.fn(async () => undefined), +}; + +const DISPATCHER: Dispatcher = { dispatch: vi.fn(async () => ({ ok: true })) }; + +const ENTRY: SensorEntry = { + sensor_id: "hackernews", + pkg: "@fake/sensor-hackernews", + skill_id: "fake", + enabled: true, + isolated: false, + config: {}, +}; + +describe("SensorRuntime.applyManifest concurrency lock", () => { + it("serializes concurrent applyManifest calls so a single sensor is only started once", async () => { + let inFlight = 0; + let maxInFlight = 0; + + startSensorMock.mockImplementation(async () => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + // Simulate slow async start. Without the lock, three concurrent + // applyManifest calls would all enter this window and inFlight + // would reach 3. + await new Promise((r) => setTimeout(r, 30)); + inFlight--; + return async () => undefined; // cleanup + }); + + const runtime = new SensorRuntime({ + dispatcher: DISPATCHER, + isolatedRunnerManager: ISOLATED_NOOP as never, + paths: PATHS, + log: () => undefined, + }); + + const results = await Promise.all([ + runtime.applyManifest([ENTRY]), + runtime.applyManifest([ENTRY]), + runtime.applyManifest([ENTRY]), + ]); + + expect(maxInFlight).toBe(1); + expect(startSensorMock).toHaveBeenCalledTimes(1); + expect(results[0]?.started).toEqual(["hackernews"]); + expect(results[1]?.started).toEqual([]); + expect(results[2]?.started).toEqual([]); + }); + + it("queued applyManifest calls execute strictly in FIFO order", async () => { + const order: string[] = []; + startSensorMock.mockImplementation(async (...args: unknown[]) => { + const ctx = (args[1] ?? {}) as Record; + const sensorId = String(((ctx as { spec?: { id?: string } }).spec?.id) ?? "?"); + order.push(`start`); + await new Promise((r) => setTimeout(r, 20)); + order.push(`done`); + void sensorId; + return async () => undefined; + }); + + const runtime = new SensorRuntime({ + dispatcher: DISPATCHER, + isolatedRunnerManager: ISOLATED_NOOP as never, + paths: PATHS, + log: () => undefined, + }); + + const entryA: SensorEntry = { ...ENTRY, sensor_id: "a", pkg: "@fake/a", skill_id: "a" }; + const entryB: SensorEntry = { ...ENTRY, sensor_id: "b", pkg: "@fake/b", skill_id: "b" }; + + await Promise.all([runtime.applyManifest([entryA]), runtime.applyManifest([entryB])]); + + // The lock guarantees no interleaving — entry B's start never overlaps + // with entry A's start. With concurrent (unlocked) execution we'd see + // ["start", "start", "done", "done"]. Locked execution gives strictly + // alternating start→done pairs. + expect(order).toEqual(["start", "done", "start", "done"]); + }); +}); From 8be4b58efddd9b63631df1a6974e1706ebdb4c1b Mon Sep 17 00:00:00 2001 From: Nova-machinepulse Date: Wed, 29 Apr 2026 21:48:13 +0800 Subject: [PATCH 7/9] chore(openclaw-plugin): prepare alpha package metadata --- openclaw-plugin/README.md | 2 +- openclaw-plugin/package-lock.json | 9 +++++---- openclaw-plugin/package.json | 5 ++--- openclaw-plugin/pnpm-lock.yaml | 10 +++++----- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/openclaw-plugin/README.md b/openclaw-plugin/README.md index 3972c6e..0f2cff0 100644 --- a/openclaw-plugin/README.md +++ b/openclaw-plugin/README.md @@ -60,7 +60,7 @@ Verify it loaded: ```bash openclaw plugins list | grep world2agent -# → │ World2Agent │ world2agent │ openclaw │ enabled │ ... │ 0.0.0-dev │ +# → │ World2Agent │ world2agent │ openclaw │ enabled │ ... │ 0.1.0-alpha.0 │ openclaw world2agent --help # → Commands: reload, sensor ``` diff --git a/openclaw-plugin/package-lock.json b/openclaw-plugin/package-lock.json index a52177d..73bfca5 100644 --- a/openclaw-plugin/package-lock.json +++ b/openclaw-plugin/package-lock.json @@ -1,15 +1,15 @@ { "name": "@world2agent/openclaw-plugin", - "version": "0.0.0-dev", + "version": "0.1.0-alpha.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@world2agent/openclaw-plugin", - "version": "0.0.0-dev", + "version": "0.1.0-alpha.0", "license": "Apache-2.0", "dependencies": { - "@world2agent/sdk": "file:../../world2agent-typescript-sdk", + "@world2agent/sdk": "0.1.0-alpha.1", "zod": "^3.25.76" }, "devDependencies": { @@ -523,7 +523,8 @@ }, "node_modules/@world2agent/sdk": { "version": "0.1.0-alpha.1", - "resolved": "file:../../world2agent-typescript-sdk", + "resolved": "https://registry.npmjs.org/@world2agent/sdk/-/sdk-0.1.0-alpha.1.tgz", + "integrity": "sha512-YfCdXPyX9Zm811fsT0kiTfCRW7iOZ4ByYZCwlqeKZbXRy8/RxJrse6KGzexfZWAXv0L8Gl8ZvOJTs4WesfIiaQ==", "license": "Apache-2.0", "engines": { "node": ">=20" diff --git a/openclaw-plugin/package.json b/openclaw-plugin/package.json index aa785e8..644e181 100644 --- a/openclaw-plugin/package.json +++ b/openclaw-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@world2agent/openclaw-plugin", - "version": "0.0.0-dev", + "version": "0.1.0-alpha.0", "description": "World2Agent native plugin for OpenClaw", "license": "Apache-2.0", "author": "MachinePulse Pte. Ltd.", @@ -40,7 +40,7 @@ "prepublishOnly": "pnpm run clean && pnpm run build" }, "dependencies": { - "@world2agent/sdk": "file:../../world2agent-typescript-sdk", + "@world2agent/sdk": "0.1.0-alpha.1", "zod": "^3.25.76" }, "devDependencies": { @@ -66,4 +66,3 @@ "access": "public" } } - diff --git a/openclaw-plugin/pnpm-lock.yaml b/openclaw-plugin/pnpm-lock.yaml index ab172bb..0e2fb53 100644 --- a/openclaw-plugin/pnpm-lock.yaml +++ b/openclaw-plugin/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@world2agent/sdk': - specifier: file:../../world2agent-typescript-sdk - version: file:../../world2agent-typescript-sdk(zod@3.25.76) + specifier: 0.1.0-alpha.1 + version: 0.1.0-alpha.1(zod@3.25.76) zod: specifier: ^3.25.76 version: 3.25.76 @@ -187,8 +187,8 @@ packages: '@vitest/utils@4.1.5': resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} - '@world2agent/sdk@file:../../world2agent-typescript-sdk': - resolution: {directory: ../../world2agent-typescript-sdk, type: directory} + '@world2agent/sdk@0.1.0-alpha.1': + resolution: {integrity: sha512-YfCdXPyX9Zm811fsT0kiTfCRW7iOZ4ByYZCwlqeKZbXRy8/RxJrse6KGzexfZWAXv0L8Gl8ZvOJTs4WesfIiaQ==} engines: {node: '>=20'} peerDependencies: zod: ^3.25.0 @@ -604,7 +604,7 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@world2agent/sdk@file:../../world2agent-typescript-sdk(zod@3.25.76)': + '@world2agent/sdk@0.1.0-alpha.1(zod@3.25.76)': dependencies: zod: 3.25.76 From d412733e50171c1bac528ac8556c631e1217ee4b Mon Sep 17 00:00:00 2001 From: Nova-machinepulse Date: Wed, 29 Apr 2026 22:13:02 +0800 Subject: [PATCH 8/9] docs(openclaw-plugin): remove contextInjection prerequisite from docs --- README.md | 14 +++----------- openclaw-plugin/README.md | 27 +++------------------------ 2 files changed, 6 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 2c9b5d6..8f97056 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Channel adapters that connect [World2Agent](https://github.com/machinepulse-ai/w | --- | --- | --- | | [`claude-code-channel`](./claude-code-channel) | [Claude Code](https://docs.claude.com/en/docs/claude-code) | MCP channel adapter + Claude Code plugin bundle. Signals arrive as in-session MCP notifications. | | [`hermes-sensor-bridge`](./hermes-sensor-bridge) | [Hermes Agent](https://hermes-agent.nousresearch.com/) | Out-of-process supervisor + webhook bridge. Each signal triggers a fresh `AIAgent.run_conversation()` with the generated handler skill auto-loaded. | -| [`openclaw-plugin`](./openclaw-plugin) | [OpenClaw](https://docs.openclaw.ai/) | Native OpenClaw plugin. Conversational install via chat (Q&A driven by the sensor's `SETUP.md`), in-process polling, dispatch via `runEmbeddedAgent` into a per-sensor session lane keyed `agent:main:w2a-` (main chat untouched). | +| [`openclaw-plugin`](./openclaw-plugin) | [OpenClaw](https://docs.openclaw.ai/) | Native OpenClaw plugin. Conversational install via chat (Q&A driven by the sensor's `SETUP.md`), in-process polling, dispatch via OpenClaw's embedded-agent runtime into a per-sensor session lane keyed `agent:main:w2a-` (main chat untouched). | --- @@ -54,14 +54,6 @@ Each signal triggers a fresh agent run against the generated handler skill. See ## Quick start — OpenClaw -Prereq — the plugin refuses to start unless `agents.defaults.contextInjection` is exactly `"continuation-skip"` in `~/.openclaw/openclaw.json`. This is hard-fail by design (the default `"always"` re-injects bootstrap on every signal and silently turns sensors into a token sink): - -```bash -jq '.agents.defaults.contextInjection = "continuation-skip"' \ - ~/.openclaw/openclaw.json > /tmp/openclaw.json.tmp && \ - mv /tmp/openclaw.json.tmp ~/.openclaw/openclaw.json -``` - Install the plugin (`--dangerously-force-unsafe-install` is required because the plugin uses `child_process` to npm-install sensor packages on demand — OpenClaw's security scan blocks it otherwise): ```bash @@ -75,9 +67,9 @@ Then in a chat session with your `main` agent, just describe what you want to su > subscribe me to Hacker News — I care about AI and security stories ``` -The bundled `world2agent-manage` skill takes over: reads the sensor's `SETUP.md`, asks you 1–3 questions to personalize the handler, writes both the config and the personalized SKILL.md, and registers the sensor — without any manual CLI work. +The bundled `world2agent-manage` skill takes over: reads the sensor's `SETUP.md`, asks you 1–3 questions to personalize the handler, writes both the config and the personalized SKILL.md, registers the sensor, and reloads the running plugin — without any manual CLI work. -> First time only: the agent will ask **you** to run `openclaw gateway restart` once after registration. It intentionally doesn't run that command itself — restarting the gateway from inside the chat would kill the gateway process and truncate the agent's reply mid-sentence. After the restart, the sensor starts polling within ~60 seconds. +> If reload returns `ok: false` (for example a gateway-call timeout), the agent will ask **you** to run `openclaw gateway restart` in your own terminal. It intentionally doesn't run that command itself — restarting the gateway from inside the chat would kill the gateway process and truncate the agent's reply mid-sentence. After reload or restart, the sensor starts polling within seconds; subsequent polls follow the sensor's configured `interval_seconds`. Signals route to a per-sensor session lane (`agent:main:w2a-`) — your `main` chat is untouched. Open the `w2a-` lane in the OpenClaw dashboard () to see how the agent reacts to each signal. See [`openclaw-plugin/README.md`](./openclaw-plugin/README.md) for the full install reference, dispatcher internals, and CLI fallback. diff --git a/openclaw-plugin/README.md b/openclaw-plugin/README.md index 0f2cff0..5f3c252 100644 --- a/openclaw-plugin/README.md +++ b/openclaw-plugin/README.md @@ -12,22 +12,7 @@ When [`deliver`](#pushing-replies-to-a-chat-platform-lark--whatsapp--telegram--) > ⚠️ OpenClaw config is **JSON**, not YAML. All steps below assume `~/.openclaw/openclaw.json`. -### 1. Set the contextInjection prerequisite - -The plugin refuses to start unless `agents.defaults.contextInjection` is exactly `"continuation-skip"` (otherwise OpenClaw's default `"always"` re-injects bootstrap on every signal and silently turns sensors into a token sink). - -```bash -# Edit in place (no `sponge` dependency — works on stock macOS / Linux) -jq '.agents.defaults.contextInjection = "continuation-skip"' \ - ~/.openclaw/openclaw.json > /tmp/openclaw.json.tmp && \ - mv /tmp/openclaw.json.tmp ~/.openclaw/openclaw.json - -# Verify -jq '.agents.defaults.contextInjection' ~/.openclaw/openclaw.json -# → "continuation-skip" -``` - -### 2. Install the plugin +### 1. Install the plugin > ⚠️ The plugin uses `child_process` for sensor subprocess management (required for `isolated: true` mode and for npm install/uninstall of sensor packages). OpenClaw's built-in security scan **blocks** plugins with `child_process` by default. The output will show a wall of `WARNING` lines listing every `child_process` site — **that is expected**. The actual success markers are at the bottom: `Linked plugin path` (or `Installed plugin`) and `Restart the gateway to load plugins`. @@ -65,7 +50,7 @@ openclaw world2agent --help # → Commands: reload, sensor ``` -### 3. Subscribe to your first source — by talking to OpenClaw +### 2. Subscribe to your first source — by talking to OpenClaw > ℹ️ By default W2A signals lane through your **existing `main` agent** but on a different sessionKey (one per sensor), so they don't pollute your normal chat session. If you'd rather route them to a dedicated agent for full isolation, set `defaultAgentId: "world2agent"` (or any other id) in this plugin's config and `openclaw agents add ` first. @@ -232,19 +217,13 @@ an enabled plugin. > stays in the session lane only. In-process sensors (the default) push > to the channel correctly. -## ContextInjection Prerequisite - -This plugin refuses to start unless `agents.defaults.contextInjection` is exactly `"continuation-skip"`. - -That check also runs before `openclaw world2agent sensor add`. There is no warning mode, no fallback mode, and no override flag. The design requires a hard failure because OpenClaw's default `"always"` setting would re-inject bootstrap on every sensor signal and silently turn high-frequency sensors into a token sink. - ## Relation to `hermes-sensor-bridge` `hermes-sensor-bridge` solved the same World2Agent runtime problem for Hermes with webhook subscriptions plus supervised subprocesses. This package keeps the same manifest shape and reuses the runner/supervisor mechanics for `isolated: true`, but the primary OpenClaw path is simpler: native plugin registration plus `runtime.subagent.run` (which internally pairs `runEmbeddedAgent` with OpenClaw's outbound delivery resolver) — no separate gateway, no HMAC ingest, no platform bootstrap. ## Troubleshooting -**Plugin install blocked by safety scanner**: that's the security warning about `child_process`. Use `--dangerously-force-unsafe-install` (see [§ Install the plugin](#2-install-the-plugin)). +**Plugin install blocked by safety scanner**: that's the security warning about `child_process`. Use `--dangerously-force-unsafe-install` (see [§ Install the plugin](#1-install-the-plugin)). **`openclaw world2agent --help` says "unknown command"**: gateway hasn't reloaded the plugin yet. Run `openclaw gateway restart`. From e3d012d91f2f506dbd75e4e98f5890221317df27 Mon Sep 17 00:00:00 2001 From: Nova-machinepulse Date: Wed, 29 Apr 2026 22:16:12 +0800 Subject: [PATCH 9/9] docs(openclaw-plugin): remove local source install section --- openclaw-plugin/README.md | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/openclaw-plugin/README.md b/openclaw-plugin/README.md index 5f3c252..313220a 100644 --- a/openclaw-plugin/README.md +++ b/openclaw-plugin/README.md @@ -23,24 +23,6 @@ openclaw plugins install @world2agent/openclaw-plugin --dangerously-force-unsafe openclaw gateway restart ``` -#### Contributors / pre-release testing (from local source) - -If you're hacking on this plugin and haven't published to npm yet: - -```bash -cd world2agent-plugins/openclaw-plugin -pnpm install -pnpm build - -# Use an ABSOLUTE path. OpenClaw's `plugins install` also accepts hook packs -# (a different concept) — a relative or `~` path can be misclassified and -# yield a confusing "HOOK.md missing in ..." error. Absolute path tells -# OpenClaw "this is the plugin you just built." -openclaw plugins install -l --dangerously-force-unsafe-install \ - "$(pwd)" -openclaw gateway restart -``` - Verify it loaded: ```bash