wattetheria 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -98,7 +98,7 @@ Read the diagram in layers:
98
98
 
99
99
  ### Operator Apps
100
100
 
101
- - `wattetheria-client-cli`
101
+ - `wattetheria` CLI
102
102
  - bootstrap and lifecycle commands: `init`, `up`, `doctor`, `upgrade-check`
103
103
  - policy, governance, MCP, brain, data, oracle, night-shift, and summary posting commands
104
104
  - cross-platform install and package scripts in `scripts/`
@@ -148,18 +148,11 @@ Read the diagram in layers:
148
148
 
149
149
  ### Tasks, Oracle, And Mailbox
150
150
 
151
- - Legacy task engine with deterministic lifecycle:
152
- - publish
153
- - claim
154
- - execute
155
- - submit
156
- - verify
157
- - settle
158
- - `market.match` task path with deterministic and witness verification modes
159
- - `swarm_bridge` adapter that maps current task execution into a `wattswarm`-oriented bridge surface
151
+ - Product-layer galaxy task definitions in `crates/kernel-core/src/tasks/galaxy_task.rs`
152
+ - `swarm_bridge` adapter for `wattswarm` topic, task/run read models, and peer/network surfaces
160
153
  - Hybrid `swarm_bridge` path for `wattswarm` topic and network read models
161
154
  - optional `--wattswarm-ui-base-url` wiring from CLI config into node runtime
162
- - topic subscribe, post, history, cursor, network-status, and peer-list bridge calls
155
+ - topic subscribe, post, history, cursor, task/run snapshots, network-status, and peer-list bridge calls
163
156
  - Oracle registry with signed feed publish, subscribe, pull, and watt-based settlement
164
157
  - Cross-subnet mailbox with send, fetch, and ack persistence
165
158
 
@@ -203,6 +196,9 @@ Read the diagram in layers:
203
196
  - Authenticated local HTTP API and WebSocket stream
204
197
  - Bearer token auth
205
198
  - Request rate limiting
199
+ - Local MCP endpoint at `POST /mcp` for attached agent runtimes; its tool catalog mirrors the
200
+ `.agent-participation/manifest.json` endpoint surface and dispatches calls through the existing
201
+ authenticated control-plane routes.
206
202
  - Append-only control-plane audit log
207
203
  - Core endpoints for health, state, events, exports, audit, night shift, autonomy, and action execution
208
204
  - Node-local client DTO endpoints:
@@ -216,7 +212,7 @@ Read the diagram in layers:
216
212
  - `/v1/client/leaderboard`
217
213
  - Public signed export endpoint:
218
214
  - `/v1/client/export` returns a signed public snapshot for local inspection
219
- - `wattetheria-gateway` ingests snapshots via wattswarm; pull data from wattetheria
215
+ - `wattetheria-gateway` can ingest snapshots either by pulling `/v1/client/export` or by receiving node pushes when the kernel is started with one or more `--gateway-url` values
220
216
  - social snapshot arrays currently include `friend_relationships`, `pending_friend_requests`, `public_blocks`, `dm_threads`, and `dm_messages`
221
217
  - additive swarm bridge views now include `swarm_task_activity`
222
218
  - Civilization endpoints for profile, metrics, emergencies, briefing, world zones/events, and mission lifecycle
@@ -568,6 +564,8 @@ Version commands:
568
564
  - `npx wattetheria --version` shows the current Wattetheria release version
569
565
  - `npx wattetheria version --images` prints the configured image refs for the current deployment
570
566
  - `npx wattetheria version --cli` shows the deployment CLI package version
567
+ - `npx wattetheria update` resolves the latest shared published image tag across the configured release images and upgrades to it
568
+ - `npx wattetheria update --tag <tag>` pins the deployment to a specific published image tag
571
569
 
572
570
  Release deployments bind-mount host-visible state by default:
573
571
 
@@ -597,7 +595,7 @@ pwsh ./scripts/deploy-release.ps1
597
595
  - `docker-compose.yml` is the local `wattetheria`-only development stack
598
596
  - `docker-compose.full.yml` is the local joint development stack for `wattetheria` + `wattswarm`
599
597
  - `docker-compose.release.yml` is the image-based release deployment asset used by the CLI and fallback scripts
600
- - `.env.release` is the release deployment environment template used by the CLI and fallback scripts
598
+ - the CLI now generates deployment environment defaults internally and resolves the latest published image release during install and update
601
599
  - `scripts/deploy-release.ps1` is a cross-platform fallback deployment entry point
602
600
  - this repository does not include `wattetheria-gateway`; gateway is a separate project and deployment unit
603
601
  - Entrypoints live in `scripts/docker-kernel-entrypoint.sh` and `scripts/docker-observatory-entrypoint.sh`
@@ -678,9 +676,21 @@ WATTETHERIA_BRAIN_PROVIDER_KIND=openai-compatible
678
676
  WATTETHERIA_BRAIN_BASE_URL=http://host.docker.internal:18789/v1
679
677
  WATTETHERIA_BRAIN_MODEL=openclaw
680
678
  WATTETHERIA_BRAIN_API_KEY_ENV=OPENCLAW_API_KEY
679
+ WATTETHERIA_GATEWAY_URLS=http://gateway.example.com:8080
681
680
  OPENCLAW_API_KEY=replace-me
682
681
  ```
683
682
 
683
+ `docker-compose.release.yml` also mounts `${WATTSWARM_HOST_STATE_DIR}/startup_config.json` into the
684
+ kernel container. If `WATTETHERIA_GATEWAY_URLS` is unset, the kernel now falls back to `gateway_urls`
685
+ saved by the Wattswarm startup UI in that file.
686
+
687
+ When Wattetheria registers `core-agent` with Wattswarm, it keeps the brain/runtime
688
+ `base_url` pointed at the OpenAI-compatible gateway for `/execute` work and exposes a
689
+ separate local `POST /agent-events` adapter on the Wattetheria control-plane endpoint
690
+ for structured agent-event callbacks. This keeps local-mode task execution and
691
+ topic/consensus flows on the existing runtime path while letting agent events reach
692
+ OpenClaw/NanoClaw-style runtimes through Wattetheria's adapter.
693
+
684
694
  When `servicenet_base_url` is configured, the control plane exposes local proxy routes for external agent discovery and execution:
685
695
 
686
696
  - `GET /v1/servicenet/agents`
@@ -688,12 +698,134 @@ When `servicenet_base_url` is configured, the control plane exposes local proxy
688
698
  - `POST /v1/servicenet/agents/:agent_id/invoke`
689
699
  - `POST /v1/servicenet/agents/:agent_id/tasks/:task_id/get`
690
700
 
701
+ `POST /v1/servicenet/agents/:agent_id/invoke` now accepts an optional `settlement` object so a
702
+ Wattetheria-hosted agent can carry its selected payment rail and bound payment account reference
703
+ into downstream A2A/service execution. Current first-party settlement shape is:
704
+
705
+ ```json
706
+ {
707
+ "message": "buy the selected itinerary",
708
+ "input": {
709
+ "offer_id": "offer-123"
710
+ },
711
+ "settlement": {
712
+ "layer": "web3",
713
+ "rail": "x402",
714
+ "request": {
715
+ "protocol": "x402",
716
+ "payment_account_ref": "payment-account-123",
717
+ "network": "base-sepolia"
718
+ }
719
+ }
720
+ }
721
+ ```
722
+
723
+ For local payment account setup, the CLI now exposes:
724
+
725
+ ```bash
726
+ cargo run -p wattetheria-client-cli -- wallet --data-dir .wattetheria create-payment-account --label settlement --network base-sepolia
727
+ cargo run -p wattetheria-client-cli -- wallet --data-dir .wattetheria import-payment-account --private-key-hex <hex> --label settlement --network base-sepolia
728
+ cargo run -p wattetheria-client-cli -- wallet --data-dir .wattetheria watch-payment-account --address 0xabc... --label inbound --network base-sepolia
729
+ cargo run -p wattetheria-client-cli -- wallet --data-dir .wattetheria list-payment-accounts
730
+ cargo run -p wattetheria-client-cli -- wallet --data-dir .wattetheria bind-payment-account --account-id <account-id>
731
+ cargo run -p wattetheria-client-cli -- wallet --data-dir .wattetheria active-payment-account
732
+ ```
733
+
734
+ The Wattetheria agent-side control plane also exposes payment session endpoints. The payment
735
+ state machine lives on the agent side, while propagation continues to use the wattswarm-backed
736
+ swarm bridge peer direct message transport. These routes persist a local payment ledger, send
737
+ payment session messages to the counterpart agent over wattswarm, and reconcile inbound payment
738
+ messages from the swarm bridge:
739
+
740
+ - `GET /v1/payments/agent-payments`
741
+ - `GET /v1/payments/agent-payments/:payment_id`
742
+ - `POST /v1/payments/agent-payments/propose`
743
+ - `POST /v1/payments/agent-payments/:payment_id/authorize`
744
+ - `POST /v1/payments/agent-payments/:payment_id/submit`
745
+ - `POST /v1/payments/agent-payments/:payment_id/settle`
746
+ - `POST /v1/payments/agent-payments/:payment_id/reject`
747
+ - `POST /v1/payments/agent-payments/:payment_id/cancel`
748
+
749
+ Receive-side flow is:
750
+
751
+ 1. counterpart agent proposes a payment
752
+ 2. wattswarm delivers the payment message over peer direct message transport
753
+ 3. Wattetheria reconciles the inbound payment session into the local ledger
754
+ 4. the attached local agent reads `/v1/payments/agent-payments?role=inbound`
755
+ 5. the local agent decides whether to authorize, reject, submit, settle, or cancel by calling the payment endpoints above
756
+
757
+ These payment endpoints are also published into `.agent-participation/manifest.json` and
758
+ `.agent-participation/README.md`, so the attached local agent host has a first-class receive-side
759
+ API surface. This path does not rely on `executor_registry_local`.
760
+
761
+ Example propose request:
762
+
763
+ ```json
764
+ {
765
+ "public_id": "captain-aurora_abcdef",
766
+ "counterpart_public_id": "broker-borealis_123456",
767
+ "amount": "2500000",
768
+ "currency": "USDT",
769
+ "rail": "x402",
770
+ "layer": "web3",
771
+ "network": "base-sepolia",
772
+ "description": "task reward"
773
+ }
774
+ ```
775
+
691
776
  When the kernel starts, it writes a node-local agent participation contract to:
692
777
 
693
778
  - `<data_dir>/.agent-participation/manifest.json`
694
779
  - `<data_dir>/.agent-participation/README.md`
695
780
 
696
- These files tell an attached agent host how to authenticate to Wattetheria and which civilization topic endpoints to call in order to participate in the wattswarm-backed network.
781
+ These files are retained as a compatibility and verification artifact. The preferred runtime
782
+ integration surface for OpenClaw, HermesAgent, and other attached agent runtimes is now the local
783
+ authenticated MCP endpoint:
784
+
785
+ - `POST <control_plane_endpoint>/mcp`
786
+
787
+ The MCP `tools/list` response uses the same endpoint keys as `.agent-participation/manifest.json`
788
+ (`list_missions`, `publish_mission`, `list_agent_payments`, `invoke_servicenet_agent`, and so on)
789
+ so operators can compare the generated manifest and the live MCP tool catalog directly. MCP
790
+ `tools/call` dispatches through the existing local control-plane routes, preserving bearer-token
791
+ auth, rate limiting, audit logging, signed event writes, and persistence behavior.
792
+
793
+ For agent runtimes that support stdio MCP servers, prefer the local proxy command instead of
794
+ configuring bearer-token headers by hand. The proxy reads `control.token` itself and
795
+ forwards MCP JSON-RPC requests to the local control plane:
796
+
797
+ ```json
798
+ {
799
+ "mcpServers": {
800
+ "wattetheria": {
801
+ "command": "npx",
802
+ "args": [
803
+ "wattetheria",
804
+ "mcp-proxy"
805
+ ]
806
+ }
807
+ }
808
+ }
809
+ ```
810
+
811
+ If the runtime is attached to a source checkout instead of the default release deployment, pass the
812
+ node data directory explicitly:
813
+
814
+ ```json
815
+ {
816
+ "mcpServers": {
817
+ "wattetheria": {
818
+ "command": "npx",
819
+ "args": [
820
+ "wattetheria",
821
+ "mcp-proxy",
822
+ "--data-dir",
823
+ "/Users/sac/Desktop/Watt/wattetheria/.wattetheria"
824
+ ]
825
+ }
826
+ }
827
+ }
828
+ ```
697
829
 
698
830
  In Docker release deployments, those files live under the host bind mount, so a local AI assistant can read them directly from:
699
831
 
@@ -35,11 +35,14 @@ services:
35
35
  WATTETHERIA_BRAIN_MODEL: ${WATTETHERIA_BRAIN_MODEL:-}
36
36
  WATTETHERIA_BRAIN_API_KEY_ENV: ${WATTETHERIA_BRAIN_API_KEY_ENV:-}
37
37
  WATTETHERIA_SERVICENET_BASE_URL: ${WATTETHERIA_SERVICENET_BASE_URL:-}
38
+ WATTETHERIA_GATEWAY_URLS: ${WATTETHERIA_GATEWAY_URLS:-}
39
+ WATTETHERIA_GATEWAY_CONFIG_PATH: ${WATTETHERIA_GATEWAY_CONFIG_PATH:-/var/lib/wattswarm/startup_config.json}
38
40
  WATTETHERIA_AUTONOMY_ENABLED: ${WATTETHERIA_AUTONOMY_ENABLED:-false}
39
41
  WATTETHERIA_AUTONOMY_INTERVAL_SEC: ${WATTETHERIA_AUTONOMY_INTERVAL_SEC:-30}
40
42
  OPENCLAW_API_KEY: ${OPENCLAW_API_KEY:-}
41
43
  volumes:
42
44
  - ${WATTETHERIA_HOST_STATE_DIR:-./data/wattetheria}:/var/lib/wattetheria
45
+ - ${WATTSWARM_HOST_STATE_DIR:-./data/wattswarm}:/var/lib/wattswarm:ro
43
46
  ports:
44
47
  - "${WATTETHERIA_CONTROL_PLANE_BIND_HOST:-127.0.0.1}:${WATTETHERIA_CONTROL_PLANE_PORT:-7777}:7777"
45
48
  extra_hosts:
package/lib/cli.js CHANGED
@@ -4,13 +4,20 @@ const os = require("node:os");
4
4
  const path = require("node:path");
5
5
  const { spawnSync } = require("node:child_process");
6
6
  const { createInterface } = require("node:readline/promises");
7
+ const readline = require("node:readline");
7
8
 
8
9
  const PACKAGE_ROOT = path.resolve(__dirname, "..");
9
10
  const PACKAGE_JSON = require(path.join(PACKAGE_ROOT, "package.json"));
10
- const RELEASE_ENV_TEMPLATE = path.join(PACKAGE_ROOT, ".env.release");
11
11
  const DEFAULT_DEPLOY_DIR = path.join(os.homedir(), ".wattetheria", "deploy");
12
12
  const DEFAULT_PROJECT_NAME = "wattetheria";
13
13
  const DEFAULT_COMMAND = "help";
14
+ const DEFAULT_IMAGE_REFS = new Map([
15
+ ["WATTETHERIA_KERNEL_IMAGE", "ghcr.io/wattetheria/wattetheria-kernel:latest"],
16
+ ["WATTETHERIA_OBSERVATORY_IMAGE", "ghcr.io/wattetheria/wattetheria-observatory:latest"],
17
+ ["WATTSWARM_KERNEL_IMAGE", "ghcr.io/wattetheria/wattswarm-kernel:latest"],
18
+ ["WATTSWARM_RUNTIME_IMAGE", "ghcr.io/wattetheria/wattswarm-runtime:latest"],
19
+ ["WATTSWARM_WORKER_IMAGE", "ghcr.io/wattetheria/wattswarm-worker:latest"]
20
+ ]);
14
21
  const IMAGE_KEYS = [
15
22
  "WATTETHERIA_KERNEL_IMAGE",
16
23
  "WATTETHERIA_OBSERVATORY_IMAGE",
@@ -19,6 +26,48 @@ const IMAGE_KEYS = [
19
26
  "WATTSWARM_WORKER_IMAGE"
20
27
  ];
21
28
  const HOST_STATE_DIR_KEYS = ["WATTETHERIA_HOST_STATE_DIR", "WATTSWARM_HOST_STATE_DIR"];
29
+ const REGISTRY_TAG_PAGE_SIZE = 1000;
30
+ const DEFAULT_ENV_ENTRIES = [
31
+ ...DEFAULT_IMAGE_REFS.entries(),
32
+ ["WATTETHERIA_CONTROL_PLANE_BIND_HOST", "127.0.0.1"],
33
+ ["WATTETHERIA_CONTROL_PLANE_PORT", "7777"],
34
+ ["WATTETHERIA_OBSERVATORY_BIND_HOST", "127.0.0.1"],
35
+ ["WATTETHERIA_OBSERVATORY_PORT", "8780"],
36
+ ["WATTSWARM_UI_BIND_HOST", "127.0.0.1"],
37
+ ["WATTSWARM_UI_PORT", "7788"],
38
+ ["WATTSWARM_SYNC_GRPC_BIND_HOST", "127.0.0.1"],
39
+ ["WATTSWARM_SYNC_GRPC_PORT", "7791"],
40
+ ["WATTSWARM_P2P_HOST_PORT", "4001"],
41
+ ["WATTSWARM_UDP_ANNOUNCE_HOST_PORT", "37931"],
42
+ ["WATTETHERIA_HOST_STATE_DIR", "./data/wattetheria"],
43
+ ["WATTSWARM_HOST_STATE_DIR", "./data/wattswarm"],
44
+ ["WATTETHERIA_RUNTIME_ENV_FILE", ".env.release.local"],
45
+ ["WATTETHERIA_AGENT_CONTROL_PLANE_ENDPOINT", "http://127.0.0.1:7777"],
46
+ ["WATTETHERIA_AGENT_WATTSWARM_UI_BASE_URL", "http://127.0.0.1:7788"],
47
+ ["WATTETHERIA_AGENT_WATTSWARM_SYNC_GRPC_ENDPOINT", "http://127.0.0.1:7791"],
48
+ ["WATTETHERIA_AGENT_HOST_DATA_DIR", "./data/wattetheria"],
49
+ ["WATTETHERIA_BRAIN_PROVIDER_KIND", "rules"],
50
+ ["WATTETHERIA_BRAIN_BASE_URL", ""],
51
+ ["WATTETHERIA_BRAIN_MODEL", ""],
52
+ ["WATTETHERIA_BRAIN_API_KEY_ENV", ""],
53
+ ["WATTETHERIA_SERVICENET_BASE_URL", ""],
54
+ ["WATTETHERIA_AUTONOMY_ENABLED", "false"],
55
+ ["WATTETHERIA_AUTONOMY_INTERVAL_SEC", "30"],
56
+ ["OPENCLAW_API_KEY", ""],
57
+ ["WATTSWARM_PG_DB", "wattswarm"],
58
+ ["WATTSWARM_PG_USER", "postgres"],
59
+ ["WATTSWARM_PG_PASSWORD", "replace-with-strong-password"],
60
+ ["WATTSWARM_P2P_ENABLED", "true"],
61
+ ["WATTSWARM_P2P_MDNS", "true"],
62
+ ["WATTSWARM_P2P_PORT", "4001"],
63
+ ["WATTSWARM_WORKER_CONCURRENCY", "16"],
64
+ ["WATTSWARM_WORKER_POLL_MS", "250"],
65
+ ["WATTSWARM_WORKER_LEASE_MS", "30000"],
66
+ ["WATTSWARM_UDP_ANNOUNCE_ENABLED", "false"],
67
+ ["WATTSWARM_UDP_ANNOUNCE_MODE", "multicast"],
68
+ ["WATTSWARM_UDP_ANNOUNCE_ADDR", "239.255.42.99"],
69
+ ["WATTSWARM_UDP_ANNOUNCE_PORT", "37931"]
70
+ ];
22
71
  const DOCKER_INSTALL_URLS = {
23
72
  darwin: "https://www.docker.com/products/docker-desktop/",
24
73
  win32: "https://www.docker.com/products/docker-desktop/",
@@ -42,10 +91,11 @@ Commands:
42
91
  install Prepare deployment, pull images, and start the stack
43
92
  start Start an existing deployment
44
93
  status Show docker compose status
45
- update Update image tags, pull, and restart
94
+ update Resolve latest published release, pull, and restart
46
95
  stop Stop the deployment
47
96
  uninstall Stop the deployment and optionally remove volumes
48
97
  logs Show docker compose logs
98
+ mcp-proxy Run stdio MCP proxy for the local Wattetheria node
49
99
  doctor Check local prerequisites
50
100
  help Show this help
51
101
 
@@ -56,10 +106,12 @@ Options:
56
106
  --dir <path> Deployment directory (default: ${DEFAULT_DEPLOY_DIR})
57
107
  --project-name <name> Docker compose project name (default: ${DEFAULT_PROJECT_NAME})
58
108
  --tag <tag> Override all release image tags
59
- --force Refresh deployment templates from package assets
109
+ --force Refresh deployment defaults and compose assets
60
110
  --no-health-checks Skip HTTP health checks
61
111
  --volumes With uninstall, remove named docker volumes
62
112
  --purge With uninstall, remove the deployment directory
113
+ --data-dir <path> With mcp-proxy, override Wattetheria host state directory
114
+ --control-plane <url> With mcp-proxy, override local control-plane endpoint
63
115
  `);
64
116
  }
65
117
 
@@ -82,6 +134,8 @@ function parseArgs(argv) {
82
134
  healthChecks: true,
83
135
  volumes: false,
84
136
  purge: false,
137
+ dataDir: null,
138
+ controlPlane: null,
85
139
  composeArgs: [],
86
140
  versionTarget: "release",
87
141
  includeImages: false
@@ -107,6 +161,10 @@ function parseArgs(argv) {
107
161
  options.volumes = true;
108
162
  } else if (arg === "--purge") {
109
163
  options.purge = true;
164
+ } else if (arg === "--data-dir") {
165
+ options.dataDir = requireValue(arg, argv[++index]);
166
+ } else if (arg === "--control-plane") {
167
+ options.controlPlane = requireValue(arg, argv[++index]);
110
168
  } else if (arg === "--") {
111
169
  options.composeArgs = argv.slice(index + 1);
112
170
  break;
@@ -179,6 +237,193 @@ function extractImageTag(imageRef) {
179
237
  return imageRef.slice(lastColon + 1).trim();
180
238
  }
181
239
 
240
+ function stripImageTag(imageRef) {
241
+ if (!imageRef) {
242
+ return "";
243
+ }
244
+ const lastColon = imageRef.lastIndexOf(":");
245
+ const lastSlash = imageRef.lastIndexOf("/");
246
+ if (lastColon <= lastSlash) {
247
+ return imageRef.trim();
248
+ }
249
+ return imageRef.slice(0, lastColon).trim();
250
+ }
251
+
252
+ function parseImageReference(imageRef) {
253
+ const normalized = stripImageTag(imageRef);
254
+ if (!normalized) {
255
+ throw new Error(`Invalid image reference: ${imageRef}`);
256
+ }
257
+
258
+ const segments = normalized.split("/");
259
+ const first = segments[0];
260
+ const hasExplicitRegistry = first.includes(".") || first.includes(":") || first === "localhost";
261
+ const registry = hasExplicitRegistry ? first : "registry-1.docker.io";
262
+ const repositorySegments = hasExplicitRegistry ? segments.slice(1) : segments;
263
+ if (repositorySegments.length === 0) {
264
+ throw new Error(`Invalid image repository: ${imageRef}`);
265
+ }
266
+
267
+ if (!hasExplicitRegistry && repositorySegments.length === 1) {
268
+ repositorySegments.unshift("library");
269
+ }
270
+
271
+ return {
272
+ registry,
273
+ repository: repositorySegments.join("/")
274
+ };
275
+ }
276
+
277
+ function parseReleaseTag(tag) {
278
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec((tag || "").trim());
279
+ if (!match) {
280
+ return null;
281
+ }
282
+ return {
283
+ raw: tag,
284
+ major: Number.parseInt(match[1], 10),
285
+ minor: Number.parseInt(match[2], 10),
286
+ patch: Number.parseInt(match[3], 10),
287
+ prerelease: match[4] || ""
288
+ };
289
+ }
290
+
291
+ function compareReleaseTags(left, right) {
292
+ const a = parseReleaseTag(left);
293
+ const b = parseReleaseTag(right);
294
+ if (!a && !b) {
295
+ return String(left).localeCompare(String(right));
296
+ }
297
+ if (!a) {
298
+ return -1;
299
+ }
300
+ if (!b) {
301
+ return 1;
302
+ }
303
+ for (const key of ["major", "minor", "patch"]) {
304
+ if (a[key] !== b[key]) {
305
+ return a[key] - b[key];
306
+ }
307
+ }
308
+ if (!a.prerelease && b.prerelease) {
309
+ return 1;
310
+ }
311
+ if (a.prerelease && !b.prerelease) {
312
+ return -1;
313
+ }
314
+ return a.prerelease.localeCompare(b.prerelease);
315
+ }
316
+
317
+ function parseAuthChallenge(header) {
318
+ if (!header) {
319
+ return null;
320
+ }
321
+ const match = /^Bearer\s+(.*)$/i.exec(header.trim());
322
+ if (!match) {
323
+ return null;
324
+ }
325
+ const params = new Map();
326
+ for (const [, key, value] of match[1].matchAll(/([a-zA-Z_]+)="([^"]*)"/g)) {
327
+ params.set(key, value);
328
+ }
329
+ const realm = params.get("realm");
330
+ if (!realm) {
331
+ return null;
332
+ }
333
+ return {
334
+ realm,
335
+ service: params.get("service") || "",
336
+ scope: params.get("scope") || ""
337
+ };
338
+ }
339
+
340
+ async function fetchRegistryAccessToken(challenge) {
341
+ const url = new URL(challenge.realm);
342
+ if (challenge.service) {
343
+ url.searchParams.set("service", challenge.service);
344
+ }
345
+ if (challenge.scope) {
346
+ url.searchParams.set("scope", challenge.scope);
347
+ }
348
+
349
+ const response = await fetch(url, { method: "GET" });
350
+ if (!response.ok) {
351
+ throw new Error(`Failed to resolve registry token: ${response.status} ${response.statusText}`);
352
+ }
353
+ const payload = await response.json();
354
+ const token = payload.token || payload.access_token;
355
+ if (!token) {
356
+ throw new Error("Registry token response did not contain an access token.");
357
+ }
358
+ return token;
359
+ }
360
+
361
+ async function fetchRegistryResponse(url, token = "") {
362
+ const headers = {
363
+ Accept: "application/json"
364
+ };
365
+ if (token) {
366
+ headers.Authorization = `Bearer ${token}`;
367
+ }
368
+ const response = await fetch(url, { method: "GET", headers });
369
+ if (response.status === 401 && !token) {
370
+ const challenge = parseAuthChallenge(response.headers.get("www-authenticate"));
371
+ if (!challenge) {
372
+ throw new Error(`Registry authentication challenge is missing for ${url}`);
373
+ }
374
+ const resolvedToken = await fetchRegistryAccessToken(challenge);
375
+ return fetchRegistryResponse(url, resolvedToken);
376
+ }
377
+ if (!response.ok) {
378
+ throw new Error(`Registry request failed for ${url}: ${response.status} ${response.statusText}`);
379
+ }
380
+ return response;
381
+ }
382
+
383
+ function resolveRegistryNextPage(linkHeader, currentUrl) {
384
+ if (!linkHeader) {
385
+ return "";
386
+ }
387
+ const match = /<([^>]+)>;\s*rel="next"/i.exec(linkHeader);
388
+ if (!match) {
389
+ return "";
390
+ }
391
+ return new URL(match[1], currentUrl).toString();
392
+ }
393
+
394
+ async function listPublishedImageTags(imageRef) {
395
+ const { registry, repository } = parseImageReference(imageRef);
396
+ const collected = new Set();
397
+ let nextUrl = `https://${registry}/v2/${repository}/tags/list?n=${REGISTRY_TAG_PAGE_SIZE}`;
398
+ while (nextUrl) {
399
+ const response = await fetchRegistryResponse(nextUrl);
400
+ const payload = await response.json();
401
+ for (const tag of payload.tags || []) {
402
+ if (tag && tag.trim()) {
403
+ collected.add(tag.trim());
404
+ }
405
+ }
406
+ nextUrl = resolveRegistryNextPage(response.headers.get("link"), nextUrl);
407
+ }
408
+ return [...collected];
409
+ }
410
+
411
+ function findLatestCommonReleaseTag(tagLists) {
412
+ if (tagLists.length === 0) {
413
+ return "";
414
+ }
415
+ const [first, ...rest] = tagLists.map((tags) => new Set(tags));
416
+ const common = [...first].filter((tag) => rest.every((tags) => tags.has(tag)));
417
+ const releaseCandidates = common
418
+ .map((tag) => ({ tag, parsed: parseReleaseTag(tag) }))
419
+ .filter((entry) => entry.parsed);
420
+ const stableCandidates = releaseCandidates.filter((entry) => !entry.parsed.prerelease);
421
+ const ranked = (stableCandidates.length > 0 ? stableCandidates : releaseCandidates)
422
+ .map((entry) => entry.tag)
423
+ .sort(compareReleaseTags);
424
+ return ranked.at(-1) || "";
425
+ }
426
+
182
427
  function resolveEnvReference(value, envMap, seen = new Set()) {
183
428
  if (!value) {
184
429
  return "";
@@ -210,6 +455,22 @@ function getReleaseImageMap(filePath) {
210
455
  return images;
211
456
  }
212
457
 
458
+ function getReleaseImageMapFromEnv(envMap) {
459
+ const images = new Map();
460
+ for (const key of IMAGE_KEYS) {
461
+ const rawValue = envMap.get(key);
462
+ if (!rawValue) {
463
+ continue;
464
+ }
465
+ images.set(key, resolveEnvReference(rawValue.trim(), envMap));
466
+ }
467
+ return images;
468
+ }
469
+
470
+ function defaultEnvMap() {
471
+ return new Map(DEFAULT_ENV_ENTRIES);
472
+ }
473
+
213
474
  function getReleaseSource(options) {
214
475
  const deployEnvPath = envFilePath(options);
215
476
  if (fs.existsSync(deployEnvPath)) {
@@ -219,20 +480,24 @@ function getReleaseSource(options) {
219
480
  };
220
481
  }
221
482
  return {
222
- kind: "template",
223
- path: RELEASE_ENV_TEMPLATE
483
+ kind: "uninitialized",
484
+ path: deployEnvPath
224
485
  };
225
486
  }
226
487
 
227
488
  function getReleaseVersionInfo(options) {
228
489
  const source = getReleaseSource(options);
229
- const images = getReleaseImageMap(source.path);
490
+ const images = source.kind === "deployment"
491
+ ? getReleaseImageMap(source.path)
492
+ : getReleaseImageMapFromEnv(defaultEnvMap());
230
493
  const tags = IMAGE_KEYS
231
494
  .map((key) => extractImageTag(images.get(key)))
232
495
  .filter(Boolean);
233
- const version = tags.length === IMAGE_KEYS.length && new Set(tags).size === 1
234
- ? tags[0]
235
- : "custom";
496
+ const version = source.kind === "uninitialized"
497
+ ? "uninitialized"
498
+ : (tags.length === IMAGE_KEYS.length && new Set(tags).size === 1
499
+ ? tags[0]
500
+ : "custom");
236
501
  return {
237
502
  version,
238
503
  images,
@@ -412,15 +677,14 @@ async function ensureDockerAvailable(options = {}) {
412
677
  function ensureDeploymentAssets(options) {
413
678
  fs.mkdirSync(options.dir, { recursive: true });
414
679
 
415
- const templateEnvPath = path.join(PACKAGE_ROOT, ".env.release");
416
680
  const templateComposePath = path.join(PACKAGE_ROOT, "docker-compose.release.yml");
417
681
  const targetEnvPath = envFilePath(options);
418
682
  const targetComposePath = composeFilePath(options);
419
683
 
420
684
  if (options.force || !fs.existsSync(targetEnvPath)) {
421
- fs.copyFileSync(templateEnvPath, targetEnvPath);
685
+ writeEnvFile(targetEnvPath, defaultEnvMap());
422
686
  } else {
423
- mergeNewTemplateKeys(templateEnvPath, targetEnvPath);
687
+ mergeNewDefaultKeys(targetEnvPath);
424
688
  }
425
689
  if (options.force || !fs.existsSync(targetComposePath)) {
426
690
  fs.copyFileSync(templateComposePath, targetComposePath);
@@ -471,8 +735,8 @@ function writeEnvFile(filePath, envMap) {
471
735
  fs.writeFileSync(filePath, `${lines.join("\n")}\n`);
472
736
  }
473
737
 
474
- function mergeNewTemplateKeys(templatePath, targetPath) {
475
- const templateMap = readEnvFile(templatePath);
738
+ function mergeNewDefaultKeys(targetPath) {
739
+ const templateMap = defaultEnvMap();
476
740
  const targetMap = readEnvFile(targetPath);
477
741
  let added = 0;
478
742
  for (const [key, value] of templateMap.entries()) {
@@ -508,32 +772,60 @@ function ensureHostStateDirectories(baseDir, envMap) {
508
772
  }
509
773
  }
510
774
 
511
- function syncImageTagsFromTemplate(options) {
775
+ async function syncImageTagsToLatestPublishedRelease(options) {
512
776
  if (options.tag) {
513
- return;
777
+ return options.tag;
514
778
  }
779
+
515
780
  const targetPath = envFilePath(options);
516
- const templateMap = readEnvFile(RELEASE_ENV_TEMPLATE);
517
781
  const targetMap = readEnvFile(targetPath);
782
+ const images = getReleaseImageMapFromEnv(targetMap);
783
+ const missing = IMAGE_KEYS.filter((key) => !images.get(key));
784
+ if (missing.length > 0) {
785
+ throw new Error(`Release image refs are missing from deployment env: ${missing.join(", ")}`);
786
+ }
787
+
788
+ const tagLists = [];
789
+ for (const key of IMAGE_KEYS) {
790
+ const imageRef = images.get(key);
791
+ const tags = await listPublishedImageTags(imageRef);
792
+ if (tags.length === 0) {
793
+ throw new Error(`No published tags found for ${stripImageTag(imageRef)}`);
794
+ }
795
+ tagLists.push(tags);
796
+ }
797
+
798
+ const latestTag = findLatestCommonReleaseTag(tagLists);
799
+ if (!latestTag) {
800
+ throw new Error(
801
+ "Could not find a shared published release tag across all configured images."
802
+ );
803
+ }
804
+
805
+ const currentTag = targetMap.get("RELEASE_TAG") || "";
806
+ if (currentTag.trim() !== latestTag) {
807
+ targetMap.set("RELEASE_TAG", latestTag);
808
+ }
809
+
518
810
  let changed = false;
519
- for (const key of [...IMAGE_KEYS, "RELEASE_TAG"]) {
520
- const templateValue = templateMap.get(key);
521
- if (!templateValue) {
811
+ for (const key of IMAGE_KEYS) {
812
+ const current = targetMap.get(key);
813
+ if (!current) {
522
814
  continue;
523
815
  }
524
- const resolved = resolveEnvReference(templateValue.trim(), templateMap);
525
- const current = targetMap.get(key);
526
- const currentResolved = current ? resolveEnvReference(current.trim(), targetMap) : "";
527
- if (resolved !== currentResolved) {
528
- targetMap.set(key, templateValue);
816
+ const next = `${stripImageTag(resolveEnvReference(current.trim(), targetMap))}:${latestTag}`;
817
+ if (resolveEnvReference(current.trim(), targetMap) !== next) {
818
+ targetMap.set(key, next);
529
819
  changed = true;
530
820
  }
531
821
  }
532
- if (changed) {
822
+ if (changed || currentTag.trim() !== latestTag) {
533
823
  writeEnvFile(targetPath, targetMap);
534
- const newTag = extractImageTag(resolveEnvReference(templateMap.get(IMAGE_KEYS[0]) || "", templateMap));
535
- console.log(`Updated image tags to ${newTag || "latest template values"}.`);
824
+ console.log(`Resolved latest published release tag ${latestTag}.`);
825
+ } else {
826
+ console.log(`Release images are already pinned to latest published tag ${latestTag}.`);
536
827
  }
828
+ return latestTag;
537
829
  }
538
830
 
539
831
  function pinImageTags(envMap, tag) {
@@ -622,6 +914,31 @@ function getEnvValue(envMap, key, defaultValue) {
622
914
  return value && value.trim() ? value : defaultValue;
623
915
  }
624
916
 
917
+ function resolveConfiguredPath(baseDir, configured) {
918
+ if (!configured || !configured.trim()) {
919
+ return "";
920
+ }
921
+ return path.isAbsolute(configured) ? configured : path.resolve(baseDir, configured);
922
+ }
923
+
924
+ function resolveMcpProxyConfig(options) {
925
+ const envPath = envFilePath(options);
926
+ const envMap = fs.existsSync(envPath) ? readEnvFile(envPath) : defaultEnvMap();
927
+ const dataDir = options.dataDir
928
+ ? path.resolve(options.dataDir)
929
+ : resolveConfiguredPath(
930
+ options.dir,
931
+ getEnvValue(envMap, "WATTETHERIA_HOST_STATE_DIR", "./data/wattetheria")
932
+ );
933
+ const tokenPath = path.join(dataDir, "control.token");
934
+ const host = getEnvValue(envMap, "WATTETHERIA_CONTROL_PLANE_BIND_HOST", "127.0.0.1");
935
+ const port = getEnvValue(envMap, "WATTETHERIA_CONTROL_PLANE_PORT", "7777");
936
+ return {
937
+ endpoint: (options.controlPlane || `http://${host}:${port}`).replace(/\/+$/, ""),
938
+ tokenPath
939
+ };
940
+ }
941
+
625
942
  async function runHealthChecks(options) {
626
943
  const envMap = readEnvFile(envFilePath(options));
627
944
  const kernelHost = getEnvValue(envMap, "WATTETHERIA_CONTROL_PLANE_BIND_HOST", "127.0.0.1");
@@ -656,6 +973,11 @@ function printSummary(options) {
656
973
  async function install(options) {
657
974
  await ensureDockerAvailable({ interactive: true });
658
975
  ensureDeploymentAssets(options);
976
+ if (options.tag) {
977
+ console.log(`Pinning release images to requested tag ${options.tag}.`);
978
+ } else {
979
+ await syncImageTagsToLatestPublishedRelease(options);
980
+ }
659
981
  runCompose(options, ["config"], true);
660
982
  console.log("Pulling release images...");
661
983
  runCompose(options, ["pull"]);
@@ -690,7 +1012,11 @@ async function update(options) {
690
1012
  throw new Error("Deployment is not initialized. Run install first.");
691
1013
  }
692
1014
  ensureDeploymentAssets(options);
693
- syncImageTagsFromTemplate(options);
1015
+ if (options.tag) {
1016
+ console.log(`Pinning release images to requested tag ${options.tag}.`);
1017
+ } else {
1018
+ await syncImageTagsToLatestPublishedRelease(options);
1019
+ }
694
1020
  console.log("Pulling updated images...");
695
1021
  runCompose(options, ["pull"]);
696
1022
  console.log("Restarting release stack...");
@@ -724,6 +1050,94 @@ async function logs(options) {
724
1050
  runCompose(options, ["logs", ...options.composeArgs]);
725
1051
  }
726
1052
 
1053
+ async function mcpProxy(options) {
1054
+ const { endpoint, tokenPath } = resolveMcpProxyConfig(options);
1055
+ if (!fs.existsSync(tokenPath)) {
1056
+ throw new Error(
1057
+ [
1058
+ `Wattetheria control token not found: ${tokenPath}`,
1059
+ "Start or initialize the local node first, or pass --data-dir <path>."
1060
+ ].join("\n")
1061
+ );
1062
+ }
1063
+ const token = fs.readFileSync(tokenPath, "utf8").trim();
1064
+ if (!token) {
1065
+ throw new Error(`Wattetheria control token is empty: ${tokenPath}`);
1066
+ }
1067
+
1068
+ const input = readline.createInterface({
1069
+ input: process.stdin,
1070
+ crlfDelay: Infinity,
1071
+ terminal: false
1072
+ });
1073
+
1074
+ for await (const line of input) {
1075
+ const trimmed = line.trim();
1076
+ if (!trimmed) {
1077
+ continue;
1078
+ }
1079
+ let request;
1080
+ try {
1081
+ request = JSON.parse(trimmed);
1082
+ } catch (error) {
1083
+ writeMcpResponse({
1084
+ jsonrpc: "2.0",
1085
+ id: null,
1086
+ error: {
1087
+ code: -32700,
1088
+ message: `parse error: ${error.message}`
1089
+ }
1090
+ });
1091
+ continue;
1092
+ }
1093
+
1094
+ const hasId = Object.prototype.hasOwnProperty.call(request, "id");
1095
+ const response = await forwardMcpRequest(endpoint, token, request);
1096
+ if (hasId) {
1097
+ writeMcpResponse(response);
1098
+ }
1099
+ }
1100
+ }
1101
+
1102
+ async function forwardMcpRequest(endpoint, token, request) {
1103
+ try {
1104
+ const response = await fetch(`${endpoint}/mcp`, {
1105
+ method: "POST",
1106
+ headers: {
1107
+ authorization: `Bearer ${token}`,
1108
+ "content-type": "application/json"
1109
+ },
1110
+ body: JSON.stringify(request)
1111
+ });
1112
+ const payload = await response.json().catch(() => null);
1113
+ if (response.ok) {
1114
+ return payload;
1115
+ }
1116
+ return {
1117
+ jsonrpc: "2.0",
1118
+ id: request.id ?? null,
1119
+ error: {
1120
+ code: -32000,
1121
+ message: `local Wattetheria MCP returned HTTP ${response.status}`,
1122
+ data: payload
1123
+ }
1124
+ };
1125
+ } catch (error) {
1126
+ return {
1127
+ jsonrpc: "2.0",
1128
+ id: request.id ?? null,
1129
+ error: {
1130
+ code: -32000,
1131
+ message: error.message
1132
+ }
1133
+ };
1134
+ }
1135
+ }
1136
+
1137
+ function writeMcpResponse(response) {
1138
+ process.stdout.write(`${JSON.stringify(response)}\n`);
1139
+ }
1140
+
727
1141
  function doctor() {
728
1142
  const status = getDockerStatus();
729
1143
  if (!status.ready) {
@@ -761,7 +1175,7 @@ function printImages(options) {
761
1175
  }
762
1176
 
763
1177
  function shouldPrintBanner(command) {
764
- return !["help", "--help", "-h", "version", "images"].includes(command);
1178
+ return !["help", "--help", "-h", "version", "images", "mcp-proxy"].includes(command);
765
1179
  }
766
1180
 
767
1181
  function printBanner(options) {
@@ -806,6 +1220,9 @@ async function run(argv) {
806
1220
  case "logs":
807
1221
  await logs(options);
808
1222
  return;
1223
+ case "mcp-proxy":
1224
+ await mcpProxy(options);
1225
+ return;
809
1226
  case "doctor":
810
1227
  doctor();
811
1228
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wattetheria",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Wattetheria deployment CLI",
5
5
  "license": "Apache-2.0",
6
6
  "type": "commonjs",
@@ -9,7 +9,6 @@
9
9
  "bin/",
10
10
  "lib/",
11
11
  "docker-compose.release.yml",
12
- ".env.release",
13
12
  "README.md",
14
13
  "LICENSE"
15
14
  ],
package/.env.release DELETED
@@ -1,58 +0,0 @@
1
- # Coordinated release image set
2
- RELEASE_TAG=1.0.3
3
-
4
- WATTETHERIA_KERNEL_IMAGE=ghcr.io/wattetheria/wattetheria-kernel:${RELEASE_TAG}
5
- WATTETHERIA_OBSERVATORY_IMAGE=ghcr.io/wattetheria/wattetheria-observatory:${RELEASE_TAG}
6
- WATTSWARM_KERNEL_IMAGE=ghcr.io/wattetheria/wattswarm-kernel:${RELEASE_TAG}
7
- WATTSWARM_RUNTIME_IMAGE=ghcr.io/wattetheria/wattswarm-runtime:${RELEASE_TAG}
8
- WATTSWARM_WORKER_IMAGE=ghcr.io/wattetheria/wattswarm-worker:${RELEASE_TAG}
9
-
10
- # Host bindings
11
- WATTETHERIA_CONTROL_PLANE_BIND_HOST=127.0.0.1
12
- WATTETHERIA_CONTROL_PLANE_PORT=7777
13
- WATTETHERIA_OBSERVATORY_BIND_HOST=127.0.0.1
14
- WATTETHERIA_OBSERVATORY_PORT=8780
15
- WATTSWARM_UI_BIND_HOST=127.0.0.1
16
- WATTSWARM_UI_PORT=7788
17
- WATTSWARM_SYNC_GRPC_BIND_HOST=127.0.0.1
18
- WATTSWARM_SYNC_GRPC_PORT=7791
19
- WATTSWARM_P2P_HOST_PORT=4001
20
- WATTSWARM_UDP_ANNOUNCE_HOST_PORT=37931
21
-
22
- # Host-mounted state directories for local agent access
23
- WATTETHERIA_HOST_STATE_DIR=./data/wattetheria
24
- WATTSWARM_HOST_STATE_DIR=./data/wattswarm
25
- WATTETHERIA_RUNTIME_ENV_FILE=.env.release.local
26
-
27
- # Agent-facing endpoints written into .agent-participation/manifest.json
28
- WATTETHERIA_AGENT_CONTROL_PLANE_ENDPOINT=http://127.0.0.1:7777
29
- WATTETHERIA_AGENT_WATTSWARM_UI_BASE_URL=http://127.0.0.1:7788
30
- WATTETHERIA_AGENT_WATTSWARM_SYNC_GRPC_ENDPOINT=http://127.0.0.1:7791
31
- WATTETHERIA_AGENT_HOST_DATA_DIR=./data/wattetheria
32
-
33
- # Wattetheria runtime
34
- WATTETHERIA_BRAIN_PROVIDER_KIND=rules
35
- WATTETHERIA_BRAIN_BASE_URL=
36
- WATTETHERIA_BRAIN_MODEL=
37
- WATTETHERIA_BRAIN_API_KEY_ENV=
38
- WATTETHERIA_SERVICENET_BASE_URL=
39
- WATTETHERIA_AUTONOMY_ENABLED=false
40
- WATTETHERIA_AUTONOMY_INTERVAL_SEC=30
41
- OPENCLAW_API_KEY=
42
-
43
- # Wattswarm database
44
- WATTSWARM_PG_DB=wattswarm
45
- WATTSWARM_PG_USER=postgres
46
- WATTSWARM_PG_PASSWORD=replace-with-strong-password
47
-
48
- # Wattswarm runtime and network
49
- WATTSWARM_P2P_ENABLED=true
50
- WATTSWARM_P2P_MDNS=true
51
- WATTSWARM_P2P_PORT=4001
52
- WATTSWARM_WORKER_CONCURRENCY=16
53
- WATTSWARM_WORKER_POLL_MS=250
54
- WATTSWARM_WORKER_LEASE_MS=30000
55
- WATTSWARM_UDP_ANNOUNCE_ENABLED=false
56
- WATTSWARM_UDP_ANNOUNCE_MODE=multicast
57
- WATTSWARM_UDP_ANNOUNCE_ADDR=239.255.42.99
58
- WATTSWARM_UDP_ANNOUNCE_PORT=37931