diff --git a/.github/workflows/release-host-cli.yml b/.github/workflows/release-host-cli.yml
new file mode 100644
index 00000000..4e5cebdd
--- /dev/null
+++ b/.github/workflows/release-host-cli.yml
@@ -0,0 +1,68 @@
+# Copyright 2026 Parity Technologies (UK) Ltd.
+# SPDX-License-Identifier: AGPL-3.0-only
+
+# Publishes @dotli/host-cli to npm when its release tag is pushed:
+#
+# git tag host-cli-v0.1.0 && git push origin host-cli-v0.1.0
+#
+# The package versions INDEPENDENTLY of the app (scripts/set-version.ts skips
+# published packages), so its tags carry the package prefix. Publishing needs
+# the NPM_TOKEN repository secret (npm automation token with publish rights on
+# the @dotli scope).
+
+name: Release host-cli
+
+on:
+ push:
+ tags:
+ - "host-cli-v*"
+
+permissions:
+ contents: read
+ # npm --provenance signs the package against the workflow identity.
+ id-token: write
+
+jobs:
+ publish:
+ name: Build, test, publish
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ with:
+ bun-version: "1.3.6"
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Verify the tag matches the package version
+ run: |
+ set -euo pipefail
+ TAG_VERSION="${GITHUB_REF_NAME#host-cli-v}"
+ PKG_VERSION="$(jq -r .version packages/host-cli/package.json)"
+ if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
+ echo "Tag says $TAG_VERSION but packages/host-cli/package.json says $PKG_VERSION" >&2
+ exit 1
+ fi
+
+ - name: Lint
+ run: bunx --bun turbo run lint --filter=@dotli/host-cli
+
+ - name: Type check
+ run: bunx --bun turbo run typecheck --filter=@dotli/host-cli
+
+ - name: Build
+ run: bunx --bun turbo run build --filter=@dotli/host-cli
+
+ - name: Test
+ run: bunx --bun turbo run test --filter=@dotli/host-cli
+
+ - name: Publish to npm
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ run: |
+ set -euo pipefail
+ echo "//registry.npmjs.org/:_authToken=\${NODE_AUTH_TOKEN}" > "$HOME/.npmrc"
+ cd packages/host-cli
+ npm publish --access public --provenance
diff --git a/bun.lock b/bun.lock
index 9a42dc95..2820006e 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
- "configVersion": 1,
"workspaces": {
"": {
"name": "dotli",
@@ -143,6 +142,25 @@
"typescript-eslint": "^8.61.0",
},
},
+ "packages/host-cli": {
+ "name": "@dotli/host-cli",
+ "version": "0.1.0",
+ "dependencies": {
+ "@parity/truapi-host": "0.2.1",
+ "neverthrow": "^8.2.0",
+ "qrcode": "^1.5.4",
+ },
+ "devDependencies": {
+ "@dotli/eslint-config": "workspace:*",
+ "@dotli/typescript-config": "workspace:*",
+ "@parity/truapi": "0.5.1",
+ "@types/node": "^22.0.0",
+ "@types/qrcode": "^1.5.6",
+ "eslint": "^10.5.0",
+ "typescript": "~6.0.3",
+ "vitest": "^4.1.8",
+ },
+ },
"packages/metrics": {
"name": "@dotli/metrics",
"version": "0.6.0",
@@ -523,6 +541,8 @@
"@dotli/host": ["@dotli/host@workspace:apps/host"],
+ "@dotli/host-cli": ["@dotli/host-cli@workspace:packages/host-cli"],
+
"@dotli/metrics": ["@dotli/metrics@workspace:packages/metrics"],
"@dotli/protocol": ["@dotli/protocol@workspace:packages/protocol"],
@@ -1731,6 +1751,8 @@
"@commander-js/extra-typings/commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="],
+ "@dotli/host-cli/@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="],
+
"@ensdomains/content-hash/multiformats": ["multiformats@12.1.3", "", {}, "sha512-eajQ/ZH7qXZQR2AgtfpmSMizQzmyYVmCql7pdhldPuYQi4atACekbJaQplk6dWyIi10jCaFnd6pqvcEFXjbaJw=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
@@ -1799,6 +1821,8 @@
"@apideck/better-ajv-errors/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
+ "@dotli/host-cli/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+
"@polkadot-api/cli/rollup/@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="],
"@polkadot-api/cli/rollup/@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="],
diff --git a/packages/host-cli/CHANGELOG.md b/packages/host-cli/CHANGELOG.md
new file mode 100644
index 00000000..387a4d1d
--- /dev/null
+++ b/packages/host-cli/CHANGELOG.md
@@ -0,0 +1,30 @@
+
+
+# Changelog
+
+All notable changes to `@dotli/host-cli`. This package versions
+independently of the dotli app (see README, "Versioning and releases").
+
+## 0.1.0 (unreleased)
+
+Initial release: a terminal host for `@parity/truapi-host`.
+
+- The 17 typed platform callbacks, bridged through the package's own
+ generated adapter (no hand-written SCALE).
+- In-process wasm boot (`initSync`) and an in-process loopback wire for
+ same-process products.
+- Terminal presenter: pairing QR (offline, instant), progress through the
+ silent `Authenticating` window, deliberately modest confirm prompts that
+ defer content verification to the paired wallet, auto-deny on non-TTY.
+- Owner-only (0600) JSON file storage for core and product state; product
+ storage cleared on logout and on identity change.
+- Chain-connection pool keyed by genesis hash: per-lease request-id
+ rewriting, subscription routing, capped leases per socket,
+ order-preserving delivery.
+- `serializeOperationStarts`: the load-bearing product-side shim for the
+ chain-head operation-ordering hazard.
+- `explainProductError` / `isProbableSsoTimeout`: translate the untyped 180s
+ SSO timeout into actionable guidance.
diff --git a/packages/host-cli/LICENSE b/packages/host-cli/LICENSE
new file mode 100644
index 00000000..cba9f025
--- /dev/null
+++ b/packages/host-cli/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ dot.li A trustless web browser that runs in your traditional browser.
+ Copyright (C) 2026 Parity Technologies
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/packages/host-cli/README.md b/packages/host-cli/README.md
new file mode 100644
index 00000000..51d01be9
--- /dev/null
+++ b/packages/host-cli/README.md
@@ -0,0 +1,145 @@
+
+
+# @dotli/host-cli
+
+Terminal host for the TrUAPI Rust/WASM core
+([`@parity/truapi-host`](https://www.npmjs.com/package/@parity/truapi-host)).
+
+A **host** is the platform half of a TrUAPI deployment: it implements the
+core's 17 platform callbacks and owns the user-facing surface. dotli's web
+host answers the core with modals, localStorage, and a Web Worker; this
+package is its **terminal peer**: a QR in the terminal for pairing, readline
+confirm prompts, owner-only (0600) file storage, pooled WebSocket chain
+connections, and the core running in-process via `initSync`. Neither host
+depends on the other; both depend directly on `@parity/truapi-host`.
+
+```
+ @parity/truapi-host (Rust -> WASM host engine)
+ ▲ ▲
+ browser impls │ │ terminal impls
+ dotli web host (packages/ui) @dotli/host-cli (this package)
+ modals, localStorage, Worker QR, prompts, files, in-process
+```
+
+An embedding CLI app **is its own host**: it supplies its identity
+(`host: {name, icon, version}`), the wallet deeplink scheme, the
+people/bulletin genesis hashes, and the chains it serves. Nothing here is
+dotli- or network-specific.
+
+## Usage
+
+```ts
+import { createCliHost } from "@dotli/host-cli";
+import { createClient, createTransport } from "@parity/truapi";
+
+const host = await createCliHost({
+ host: { name: "my-cli", version: "1.0.0" },
+ pairing: { deeplinkScheme: "polkadotapp" },
+ people: { genesisHash: "0x…" },
+ bulletin: { genesisHash: "0x…" },
+ chains: { "0x…": { name: "Asset Hub", rpc: "wss://…" } },
+ storageDir: "~/.my-cli/truapi",
+});
+
+const product = host.createProduct({ productId: "my-app.dot" });
+const client = createClient(createTransport(product.provider));
+
+// Drive login from the product side; the host renders the QR, the
+// silent ~20s "Authenticating" window, and the session identity.
+await client.account.requestLogin({ reason: "sign in to my-cli" });
+```
+
+`examples/pair.ts` is the runnable version (network and phone required).
+
+### Wiring into `@parity/product-sdk-*`: via a TEST seam, knowingly
+
+A product stack built on `@parity/product-sdk-host` reaches this host through
+`setTruApiClient` from `@parity/product-sdk-host/testing`:
+
+```ts
+import { setTruApiClient } from "@parity/product-sdk-host/testing";
+setTruApiClient(createClient(createTransport(product.provider)));
+```
+
+That entry point is **documented as test-only** upstream ("silently reroutes
+every host accessor"). It is the only injection point that exists today; this
+package uses it knowingly, and the supported seam should be shaped by real
+consumers like this one before it is proposed upstream. Track the caveat, do
+not hide it.
+
+### papi consumers MUST wrap their provider
+
+If the product side drives chains through polkadot-api over the core's
+host-mediated provider, wrap it:
+
+```ts
+import { serializeOperationStarts } from "@dotli/host-cli";
+const provider = serializeOperationStarts(rawProviderOverTheCore);
+```
+
+The chain-head relay can deliver an operation's events **before** the
+start-response that names its `operationId`; papi drops such events silently
+and the read never settles (measured: hung 3 runs in 6, with a 155-request
+retry storm; 6/6 clean with the shim). This is load-bearing, not defensive.
+
+## What the host owns beyond the 17 callbacks
+
+Each of these is a measured finding against the core, not a guess:
+
+- **Pairing presentation.** The deeplink arrives via `AuthState.Pairing`
+ before any socket opens, so the QR renders instantly and offline.
+- **The silent `Authenticating` window.** The People-chain statement
+ round-trip runs ~20s with no callback in between; the presenter shows
+ elapsed progress so the host does not look hung.
+- **Clearing product storage on logout.** Core product-storage keys are
+ scoped by product id, NOT by account, so the next identity would inherit the
+ previous one's data. `disconnectSession()` clears the store, and a
+ different identity connecting clears it as a crash-safe backstop.
+- **Pooling chain connections.** The core opens a socket per need (three
+ People-chain sockets during pairing alone). The pool shares sockets per
+ genesis hash with per-lease id rewriting, capped leases per socket (default
+ 2, below substrate's per-connection `chainHead_v1_follow` limit), and
+ strictly order-preserving delivery.
+- **Parked `theme`/`preimage` streams.** Both are emit-once-then-stay-open
+ subscriptions; returning early reads as end-of-stream to the core.
+- **Translating the SSO timeout.** A wallet that never answers yields a bare
+ `TxError` after ~180s with no message; `explainProductError` turns it into
+ "no response from your phone" guidance. The core's own diagnosis is only a
+ `tracing` warning, which is why `logLevel` defaults to `warn`.
+
+## Security model: the phone is the trust surface
+
+`confirmUserAction` prompts are **deliberately modest**. The host cannot
+decode what it approves: `CreateTransaction` carries opaque `callData`,
+`PreimageSubmit` only a byte count. The paired wallet decodes and displays
+the authoritative content before signing, so the terminal prompt states the
+action kind, the typed metadata the host actually knows (account, chain,
+sizes, the raw-bytes-vs-text discriminant of `SignRaw`), and defers the rest
+to the phone. A prompt that pretended to summarize undecodable bytes would be
+a false trust surface.
+
+Two consequences to accept knowingly:
+
+- **No unattended signing.** The core exposes no local keypair API; every
+ signature needs the paired phone. There is no CI path.
+- **Non-interactive terminals deny.** A host that cannot ask must not
+ approve: prompts auto-deny when stdin is not a TTY.
+
+## Known seams (candidate upstream asks for `@parity/truapi-host`)
+
+- The typed-to-raw adapter (`createWasmRawCallbacks`) is not in the package's
+ exports map; it is reached by **file URL** relative to the exported wasm
+ path (`src/wasm.ts`). A `./node` export would remove this.
+- The wasm is instantiated with `initSync` from bytes; the shipped Worker
+ runtime is browser-only by construction, not necessity.
+
+## Versioning and releases
+
+This package versions **independently** of the dotli app (semver, starting
+0.1.0; breaking host-API changes bump minor pre-1.0). It is excluded from the
+repo's app-release version sync (`scripts/set-version.ts` skips published
+packages). Releases are cut by tagging `host-cli-vX.Y.Z`, which builds,
+tests, and publishes from CI. See `CHANGELOG.md`.
diff --git a/packages/host-cli/eslint.config.js b/packages/host-cli/eslint.config.js
new file mode 100644
index 00000000..887d15ee
--- /dev/null
+++ b/packages/host-cli/eslint.config.js
@@ -0,0 +1,15 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { config } from "@dotli/eslint-config/base";
+
+export default [
+ ...config,
+ {
+ languageOptions: {
+ parserOptions: {
+ tsconfigRootDir: import.meta.dirname,
+ },
+ },
+ },
+];
diff --git a/packages/host-cli/examples/pair.ts b/packages/host-cli/examples/pair.ts
new file mode 100644
index 00000000..65133dc4
--- /dev/null
+++ b/packages/host-cli/examples/pair.ts
@@ -0,0 +1,78 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// Runnable pairing demo: renders a QR in your terminal, waits for a phone
+// scan, and round-trips a product localStorage value through the paired core.
+//
+// bun examples/pair.ts # or: node --experimental-strip-types
+//
+// Needs network egress to the Paseo Next V2 endpoints below, and a phone with
+// the Polkadot app to scan the QR. State lands in ~/.dotli-host-cli-example
+// (0600). Delete the directory to force a re-pair.
+
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { createClient, createTransport } from "@parity/truapi";
+import { createCliHost, explainProductError } from "../src/index.js";
+
+// Paseo Next V2, the same values dotli's web host uses. An embedding app
+// supplies its own network map: the host is parameterized, not opinionated.
+const PEOPLE_GENESIS =
+ "0xc5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5";
+const BULLETIN_GENESIS =
+ "0x8cfe6717dc4becfda2e13c488a1e2061ff2dfee96e7d031157f72d36716c0a22";
+const ASSET_HUB_GENESIS =
+ "0xbf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f";
+
+const host = await createCliHost({
+ host: { name: "host-cli example", version: "0.1.0" },
+ pairing: { deeplinkScheme: "polkadotapp" },
+ people: { genesisHash: PEOPLE_GENESIS },
+ bulletin: { genesisHash: BULLETIN_GENESIS },
+ chains: {
+ [ASSET_HUB_GENESIS]: {
+ name: "Asset Hub",
+ rpc: "wss://paseo-asset-hub-next-rpc.polkadot.io",
+ },
+ [PEOPLE_GENESIS]: {
+ name: "People",
+ rpc: "wss://paseo-people-next-system-rpc.polkadot.io",
+ },
+ [BULLETIN_GENESIS]: {
+ name: "Bulletin",
+ rpc: "wss://paseo-bulletin-next-rpc.polkadot.io",
+ },
+ },
+ storageDir: join(homedir(), ".dotli-host-cli-example"),
+});
+
+const product = host.createProduct({ productId: "host-cli-example.dot" });
+const client = createClient(createTransport(product.provider));
+
+// Drive login from the product side. The host renders the QR and progress.
+// On an already-paired store this resolves in milliseconds with no phone.
+try {
+ const outcome = await client.account.requestLogin({
+ reason: "host-cli pairing example",
+ });
+ console.log(`requestLogin -> ${JSON.stringify(outcome)}`);
+} catch (error) {
+ // The 180s wallet timeout surfaces as a bare TxError with no message.
+ // explainProductError turns that into something a user can act on.
+ console.error(explainProductError(error) ?? error);
+ process.exit(1);
+}
+
+const written = await client.localStorage.write({
+ key: "example",
+ value: "0xdeadbeef",
+});
+console.log(`localStorage.write -> ${written.isOk() ? "Ok" : "Err"}`);
+const read = await client.localStorage.read({ key: "example" });
+console.log(
+ `localStorage.read -> ${read.isOk() ? JSON.stringify(read.value) : "Err"}`,
+);
+
+product.dispose();
+host.dispose();
+process.exit(0);
diff --git a/packages/host-cli/package.json b/packages/host-cli/package.json
new file mode 100644
index 00000000..5ba22fd9
--- /dev/null
+++ b/packages/host-cli/package.json
@@ -0,0 +1,53 @@
+{
+ "name": "@dotli/host-cli",
+ "version": "0.1.0",
+ "private": false,
+ "description": "Terminal host for the TrUAPI Rust/WASM core: pairing QR, confirm prompts, owner-only file storage, and pooled chain connections for CLI products.",
+ "license": "AGPL-3.0-only",
+ "type": "module",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/paritytech/dotli-community.git",
+ "directory": "packages/host-cli"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist",
+ "README.md",
+ "CHANGELOG.md",
+ "LICENSE"
+ ],
+ "publishConfig": {
+ "access": "public"
+ },
+ "scripts": {
+ "build": "tsc -p tsconfig.build.json",
+ "prepack": "bun run build",
+ "typecheck": "tsc --noEmit",
+ "lint": "bunx eslint src/ tests/",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "@parity/truapi-host": "0.2.1",
+ "neverthrow": "^8.2.0",
+ "qrcode": "^1.5.4"
+ },
+ "devDependencies": {
+ "@dotli/eslint-config": "workspace:*",
+ "@dotli/typescript-config": "workspace:*",
+ "@parity/truapi": "0.5.1",
+ "@types/node": "^22.0.0",
+ "@types/qrcode": "^1.5.6",
+ "eslint": "^10.5.0",
+ "typescript": "~6.0.3",
+ "vitest": "^4.1.8"
+ }
+}
diff --git a/packages/host-cli/src/callbacks.ts b/packages/host-cli/src/callbacks.ts
new file mode 100644
index 00000000..fa8f81fe
--- /dev/null
+++ b/packages/host-cli/src/callbacks.ts
@@ -0,0 +1,198 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// The 17 typed host callbacks (11 groups), implemented for a terminal host.
+// The generated adapter (`createWasmRawCallbacks`) turns these into the raw
+// SCALE surface the wasm core calls. Hand-written SCALE callbacks are exactly
+// the drift this package exists to avoid.
+
+import { ok } from "neverthrow";
+import type {
+ AuthState,
+ CoreStorageKey,
+ RequiredHostCallbacks,
+} from "@parity/truapi-host";
+import type { ChainEndpoints, ChainPool } from "./chain-pool.js";
+import { fromHex, toHex } from "./hex.js";
+import type { KeyValueStore } from "./kv.js";
+import type { HostPresenter } from "./presenter.js";
+import { describeReview } from "./reviews.js";
+
+export interface HostCallbackDeps {
+ coreStore: KeyValueStore;
+ productStore: KeyValueStore;
+ pool: ChainPool;
+ presenter: HostPresenter;
+ endpoints: ChainEndpoints;
+ theme: "Dark" | "Light";
+ /** Optional preimage retrieval backend (P2P/IPFS). Default: always a miss. */
+ lookupPreimage?: (key: Uint8Array) => Promise;
+ onAuthState: (state: AuthState) => void;
+ log?: (line: string) => void;
+}
+
+/**
+ * Flatten a typed core-storage slot to a stable, legible backing key. Slot
+ * tags are unique. The parameterized slots carry their parameters.
+ */
+export function coreSlot(key: CoreStorageKey): string {
+ switch (key.tag) {
+ case "AllowanceKeys":
+ return `AllowanceKeys:${key.value.sessionId}`;
+ case "PermissionAuthorization":
+ return `PermissionAuthorization:${key.value.productId}:${key.value.request.tag}`;
+ default:
+ return key.tag;
+ }
+}
+
+const park = (): Promise => new Promise(() => {});
+
+export function createHostCallbacks(
+ deps: HostCallbackDeps,
+): RequiredHostCallbacks {
+ const {
+ coreStore,
+ productStore,
+ pool,
+ presenter,
+ endpoints,
+ theme,
+ lookupPreimage,
+ onAuthState,
+ log,
+ } = deps;
+ let nextNotificationId = 1;
+
+ return {
+ navigation: {
+ // NOT the pairing affordance. Pairing arrives as `AuthState.Pairing`.
+ // This is "open a URL in the system browser", which a terminal host
+ // hands to the user instead of guessing at an opener.
+ async navigateTo(url) {
+ presenter.openUrl(url);
+ },
+ },
+
+ notifications: {
+ async pushNotification(notification) {
+ presenter.notify(
+ notification.deeplink === undefined
+ ? notification.text
+ : `${notification.text} (${notification.deeplink})`,
+ );
+ return { id: nextNotificationId++ };
+ },
+ async cancelNotification(id) {
+ // Notifications are printed, not retained. Cancelling is idempotently
+ // a no-op by contract.
+ log?.(`cancelNotification(${String(id)})`);
+ },
+ },
+
+ permissions: {
+ async devicePermission(request) {
+ const granted = await presenter.confirm({
+ title: `Allow access to: ${request}`,
+ details: [],
+ phoneVerifies: false,
+ });
+ return { granted };
+ },
+ async remotePermission(request) {
+ const granted = await presenter.confirm({
+ title: "Grant a product permission",
+ details: [`permission: ${JSON.stringify(request.permission)}`],
+ phoneVerifies: false,
+ });
+ return { granted };
+ },
+ },
+
+ features: {
+ async featureSupported(request) {
+ // The only feature probe today is per-chain support. This host serves
+ // exactly the chains it has endpoints for.
+ const supported =
+ request.tag === "Chain" &&
+ endpoints[request.value.genesisHash] !== undefined;
+ return { supported };
+ },
+ },
+
+ productStorage: {
+ // The core namespaces these keys itself
+ // (`truapi:product-storage:v1:::`). The key carries
+ // NO account component, which is why the host clears this store on
+ // logout (see CliHost).
+ async read(key) {
+ const hit = await productStore.get(key);
+ return hit === null ? undefined : fromHex(hit);
+ },
+ async write(key, value) {
+ await productStore.set(key, toHex(value));
+ },
+ async clear(key) {
+ await productStore.delete(key);
+ },
+ },
+
+ coreStorage: {
+ async readCoreStorage(key) {
+ const hit = await coreStore.get(coreSlot(key));
+ return hit === null ? undefined : fromHex(hit);
+ },
+ async writeCoreStorage(key, value) {
+ await coreStore.set(coreSlot(key), toHex(value));
+ },
+ async clearCoreStorage(key) {
+ await coreStore.delete(coreSlot(key));
+ },
+ },
+
+ chain: {
+ connect(genesisHash) {
+ return pool.connect(genesisHash);
+ },
+ },
+
+ auth: {
+ authStateChanged(state) {
+ onAuthState(state);
+ },
+ },
+
+ userConfirmation: {
+ confirmUserAction(review) {
+ return presenter.confirm(describeReview(review, { endpoints }));
+ },
+ },
+
+ theme: {
+ async *subscribeTheme() {
+ yield ok(theme);
+ // A terminal theme never changes mid-run. Park forever so the core's
+ // subscription stays open instead of seeing an immediate
+ // end-of-stream.
+ await park();
+ },
+ },
+
+ preimage: {
+ async *lookupPreimage(key) {
+ let value: Uint8Array | undefined;
+ try {
+ value = await lookupPreimage?.(key);
+ } catch (error) {
+ log?.(
+ `lookupPreimage(${toHex(key).slice(0, 18)}…) failed: ${String(error)}`,
+ );
+ value = undefined;
+ }
+ yield ok(value);
+ // Same contract as `theme`: emit once, then keep the stream open.
+ await park();
+ },
+ },
+ };
+}
diff --git a/packages/host-cli/src/chain-pool.ts b/packages/host-cli/src/chain-pool.ts
new file mode 100644
index 00000000..0a55740e
--- /dev/null
+++ b/packages/host-cli/src/chain-pool.ts
@@ -0,0 +1,343 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// Pooled JSON-RPC connections, keyed by genesis hash.
+//
+// The core opens a chain connection per need and does not pool (measured:
+// three People-chain sockets during pairing, plus one per boot). The web host
+// hides that behind dotli's chain broker. This is the terminal equivalent,
+// deliberately leaner.
+//
+// - Request ids are rewritten per lease, so responses route exactly.
+// - Subscription tokens are learned from string-typed results (substrate's
+// JSON-RPC v2 subscribe calls all return the token as a string result),
+// and notifications route by `params.subscription`.
+// - Delivery is synchronous in socket arrival order, so sharing a socket
+// cannot introduce message reordering. Reordering is the class of bug
+// behind the chain_head operation-ordering hazard.
+// - Each socket carries a bounded number of leases (default 2), because
+// substrate nodes cap `chainHead_v1_follow` subscriptions per connection.
+// Pooling bounds sockets per endpoint at ceil(leases / cap) instead of N.
+
+import type { JsonRpcConnection } from "@parity/truapi-host";
+import { toHex } from "./hex.js";
+
+export interface ChainEndpoint {
+ /** WebSocket JSON-RPC endpoint. */
+ rpc: string;
+ /** Optional label used in logs and confirm prompts. */
+ name?: string;
+}
+
+/** Chains this host serves, keyed by `0x`-prefixed genesis hash. */
+export type ChainEndpoints = Record;
+
+/** The subset of WebSocket the pool needs. Injectable for tests. */
+export interface SocketLike {
+ addEventListener(type: string, listener: (event: unknown) => void): void;
+ send(data: string): void;
+ close(): void;
+}
+
+export interface ChainPoolOptions {
+ endpoints: ChainEndpoints;
+ /**
+ * Leases sharing one socket. Substrate's `chainHead_v1_follow` is capped per
+ * connection, so this stays conservative by default.
+ */
+ maxLeasesPerSocket?: number;
+ /** Socket factory, injectable for tests. Defaults to node's `WebSocket`. */
+ createSocket?: (url: string) => SocketLike;
+ log?: (line: string) => void;
+}
+
+interface Lease {
+ deliver(json: string): void;
+ end(): void;
+}
+
+interface PooledSocket {
+ socket: SocketLike;
+ open: boolean;
+ closed: boolean;
+ outbox: string[];
+ leases: Set;
+ /** rewritten request id -> owner and the id to restore on the response. */
+ pending: Map;
+ /** subscription token -> owning lease. */
+ tokens: Map;
+}
+
+export interface ChainPool {
+ connect(genesisHash: Uint8Array | string): Promise;
+ /** Open sockets right now, per endpoint URL (observability and tests). */
+ socketCounts(): Record;
+ closeAll(): void;
+}
+
+export function createChainPool(options: ChainPoolOptions): ChainPool {
+ const {
+ endpoints,
+ maxLeasesPerSocket = 2,
+ createSocket = (url) => new WebSocket(url) as unknown as SocketLike,
+ log,
+ } = options;
+ const socketsByGenesis = new Map();
+ let nextRequestId = 0;
+
+ function openSocket(genesisHash: string, url: string): PooledSocket {
+ const entry: PooledSocket = {
+ socket: createSocket(url),
+ open: false,
+ closed: false,
+ outbox: [],
+ leases: new Set(),
+ pending: new Map(),
+ tokens: new Map(),
+ };
+ entry.socket.addEventListener("open", () => {
+ entry.open = true;
+ for (const request of entry.outbox.splice(0)) {
+ entry.socket.send(request);
+ }
+ });
+ entry.socket.addEventListener("message", (event) => {
+ if (entry.closed) {
+ return;
+ }
+ route(entry, String((event as { data: unknown }).data));
+ });
+ entry.socket.addEventListener("error", () => {
+ log?.(`chain[${url}] socket error`);
+ });
+ entry.socket.addEventListener("close", () => {
+ teardown(genesisHash, entry);
+ });
+ return entry;
+ }
+
+ function teardown(genesisHash: string, entry: PooledSocket): void {
+ if (entry.closed) {
+ return;
+ }
+ entry.closed = true;
+ try {
+ entry.socket.close();
+ } catch {
+ // Already gone.
+ }
+ for (const lease of [...entry.leases]) {
+ lease.end();
+ }
+ entry.leases.clear();
+ entry.pending.clear();
+ entry.tokens.clear();
+ const pool = socketsByGenesis.get(genesisHash);
+ if (pool !== undefined) {
+ const remaining = pool.filter((candidate) => candidate !== entry);
+ if (remaining.length === 0) {
+ socketsByGenesis.delete(genesisHash);
+ } else {
+ socketsByGenesis.set(genesisHash, remaining);
+ }
+ }
+ }
+
+ function route(entry: PooledSocket, json: string): void {
+ let message: Record;
+ try {
+ message = JSON.parse(json) as Record;
+ } catch {
+ log?.(`chain: dropping unparseable message (${json.slice(0, 60)}…)`);
+ return;
+ }
+
+ // A response: route by the rewritten id, restore the original.
+ if (typeof message.method !== "string") {
+ const owner =
+ typeof message.id === "string"
+ ? entry.pending.get(message.id)
+ : undefined;
+ if (owner === undefined) {
+ // The owning lease closed, or the server sent an id we never issued.
+ return;
+ }
+ entry.pending.delete(message.id as string);
+ if (typeof message.result === "string") {
+ // Every substrate subscribe call returns its token as a string result.
+ // False positives (e.g. a hex string from `chainSpec_v1_genesisHash`)
+ // are harmless: the entry simply never receives a notification.
+ entry.tokens.set(message.result, owner.lease);
+ }
+ owner.lease.deliver(JSON.stringify({ ...message, id: owner.originalId }));
+ return;
+ }
+
+ // A notification: route by subscription token.
+ const params = message.params as { subscription?: unknown } | undefined;
+ const token = params?.subscription;
+ if (typeof token === "string" || typeof token === "number") {
+ const owner = entry.tokens.get(String(token));
+ if (owner === undefined) {
+ // Token of a closed lease (its server-side subscription outlives the
+ // lease until the socket closes), or one we failed to learn. Dropping
+ // is safe for the former. The latter cannot happen for substrate's
+ // string-result subscribe calls.
+ return;
+ }
+ owner.deliver(json);
+ return;
+ }
+
+ log?.(`chain: dropping unroutable message (${json.slice(0, 80)}…)`);
+ }
+
+ function sendFrom(entry: PooledSocket, lease: Lease, request: string): void {
+ if (entry.closed) {
+ return;
+ }
+ let rewritten = request;
+ try {
+ const message = JSON.parse(request) as Record;
+ if (message.id !== undefined && message.id !== null) {
+ // "hcp" is the host-cli pool namespace. The prefix guarantees a
+ // rewritten id can never collide with an id some lease chose itself.
+ const poolId = `hcp:${String(nextRequestId++)}`;
+ entry.pending.set(poolId, { lease, originalId: message.id });
+ rewritten = JSON.stringify({ ...message, id: poolId });
+ }
+ } catch {
+ // Not JSON we can rewrite. Forward as-is (its response, if any, will be
+ // unroutable and dropped).
+ }
+ if (entry.open) {
+ entry.socket.send(rewritten);
+ } else {
+ entry.outbox.push(rewritten);
+ }
+ }
+
+ function dropLease(
+ genesisHash: string,
+ entry: PooledSocket,
+ lease: Lease,
+ ): void {
+ entry.leases.delete(lease);
+ for (const [id, owner] of [...entry.pending]) {
+ if (owner.lease === lease) {
+ entry.pending.delete(id);
+ }
+ }
+ for (const [token, owner] of [...entry.tokens]) {
+ if (owner === lease) {
+ entry.tokens.delete(token);
+ }
+ }
+ if (entry.leases.size === 0) {
+ teardown(genesisHash, entry);
+ }
+ }
+
+ return {
+ async connect(genesisHash) {
+ const genesis =
+ typeof genesisHash === "string" ? genesisHash : toHex(genesisHash);
+ const endpoint = endpoints[genesis];
+ if (endpoint === undefined) {
+ // Throwing tells the core no provider is available for this chain.
+ throw new Error(`no RPC endpoint configured for ${genesis}`);
+ }
+
+ const pool = socketsByGenesis.get(genesis) ?? [];
+ let entry = pool.find(
+ (candidate) =>
+ !candidate.closed && candidate.leases.size < maxLeasesPerSocket,
+ );
+ if (entry === undefined) {
+ entry = openSocket(genesis, endpoint.rpc);
+ socketsByGenesis.set(genesis, [...pool, entry]);
+ log?.(
+ `chain[${endpoint.name ?? genesis.slice(0, 12)}] opening socket #${String(
+ (socketsByGenesis.get(genesis) ?? []).length,
+ )}`,
+ );
+ }
+
+ const inbox: string[] = [];
+ let wake: (() => void) | null = null;
+ let ended = false;
+ const lease: Lease = {
+ deliver(json) {
+ if (ended) {
+ return;
+ }
+ inbox.push(json);
+ wake?.();
+ wake = null;
+ },
+ end() {
+ ended = true;
+ wake?.();
+ wake = null;
+ },
+ };
+ entry.leases.add(lease);
+ const owner = entry;
+
+ const close = (): void => {
+ if (ended) {
+ return;
+ }
+ lease.end();
+ dropLease(genesis, owner, lease);
+ };
+
+ return {
+ send(request: string): void {
+ if (!ended) {
+ sendFrom(owner, lease, request);
+ }
+ },
+ async *responses(): AsyncIterable {
+ try {
+ while (!ended) {
+ while (inbox.length > 0) {
+ const next = inbox.shift();
+ if (next !== undefined) {
+ yield next;
+ }
+ }
+ if (ended) {
+ break;
+ }
+ await new Promise((resolve) => {
+ wake = resolve;
+ });
+ }
+ } finally {
+ close();
+ }
+ },
+ close,
+ };
+ },
+
+ socketCounts() {
+ const counts: Record = {};
+ for (const [genesis, pool] of socketsByGenesis) {
+ const url = endpoints[genesis]?.rpc ?? genesis;
+ counts[url] = (counts[url] ?? 0) + pool.filter((s) => !s.closed).length;
+ }
+ return counts;
+ },
+
+ closeAll() {
+ for (const [genesis, pool] of [...socketsByGenesis]) {
+ for (const entry of [...pool]) {
+ teardown(genesis, entry);
+ }
+ }
+ socketsByGenesis.clear();
+ },
+ };
+}
diff --git a/packages/host-cli/src/errors.ts b/packages/host-cli/src/errors.ts
new file mode 100644
index 00000000..062c9ef7
--- /dev/null
+++ b/packages/host-cli/src/errors.ts
@@ -0,0 +1,46 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// Error translation the core does not (yet) provide.
+//
+// A wallet that never answers an SSO request produces, after ~180 seconds, a
+// BARE `{isSdkError: true, source: "tx", name: "TxError"}` with no message.
+// The core's own diagnosis ("SSO response timed out") exists only as a
+// `tracing` warning, so a product cannot tell "phone never answered" from any
+// other transaction failure. Until the timeout becomes a typed error variant
+// upstream, a bare `TxError` is treated as a probable timeout.
+
+/** Whether `error` looks like the wallet-SSO 180s timeout. */
+export function isProbableSsoTimeout(error: unknown): boolean {
+ if (typeof error !== "object" || error === null) {
+ return false;
+ }
+ const candidate = error as {
+ isSdkError?: unknown;
+ source?: unknown;
+ name?: unknown;
+ message?: unknown;
+ };
+ return (
+ candidate.isSdkError === true &&
+ candidate.source === "tx" &&
+ candidate.name === "TxError" &&
+ (candidate.message === undefined ||
+ candidate.message === null ||
+ candidate.message === "")
+ );
+}
+
+/**
+ * A user-facing explanation for errors a product call can surface through the
+ * core, or `undefined` when there is nothing better than the error itself.
+ */
+export function explainProductError(error: unknown): string | undefined {
+ if (isProbableSsoTimeout(error)) {
+ return (
+ "No response from your phone. The signing request most likely timed " +
+ "out. Open the Polkadot app, check for a pending request, and retry."
+ );
+ }
+ return undefined;
+}
diff --git a/packages/host-cli/src/hex.ts b/packages/host-cli/src/hex.ts
new file mode 100644
index 00000000..36a7390f
--- /dev/null
+++ b/packages/host-cli/src/hex.ts
@@ -0,0 +1,18 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+export const toHex = (bytes: Uint8Array): string =>
+ `0x${Buffer.from(bytes).toString("hex")}`;
+
+export const fromHex = (hex: string): Uint8Array =>
+ new Uint8Array(Buffer.from(hex.replace(/^0x/, ""), "hex"));
+
+/** Byte length of a hex string, with or without its `0x` prefix. */
+export const hexByteLength = (hex: string): number =>
+ hex.replace(/^0x/, "").length / 2;
+
+/** Shorten a key for display, e.g. `0x84ccf320…be6232`. Recognizable without clutter. */
+export const shortHex = (bytes: Uint8Array): string => {
+ const hex = toHex(bytes);
+ return hex.length <= 14 ? hex : `${hex.slice(0, 10)}…${hex.slice(-6)}`;
+};
diff --git a/packages/host-cli/src/host.ts b/packages/host-cli/src/host.ts
new file mode 100644
index 00000000..1aa2c009
--- /dev/null
+++ b/packages/host-cli/src/host.ts
@@ -0,0 +1,307 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// The CLI host: boots the Rust core in-process and owns everything the core
+// leaves to a host. That means rendering (via the presenter), owner-only
+// persistence, pooled chain connections, and clearing product storage across
+// identities.
+//
+// It is parameterized by the EMBEDDING app's metadata: for a terminal app the
+// app is its own host (d3pot is not "a product inside dotli", it IS the
+// host), so name/icon/version, the pairing deeplink scheme, and the
+// people/bulletin genesis hashes all come from the caller.
+
+import { join } from "node:path";
+import type { AuthState, WireProvider } from "@parity/truapi-host";
+import { createChainPool, type ChainEndpoints } from "./chain-pool.js";
+import { createHostCallbacks } from "./callbacks.js";
+import { toHex } from "./hex.js";
+import { FileKeyValueStore } from "./kv.js";
+import { createLoopbackProvider } from "./loopback.js";
+import { createTerminalPresenter, type HostPresenter } from "./presenter.js";
+import { loadWasmCore } from "./wasm.js";
+
+export interface CliHostConfig {
+ /** Metadata describing the embedding app, shown by the wallet on pairing. */
+ host: { name: string; icon?: string; version?: string };
+ /** Wallet pairing deeplink scheme (the wallet's URI scheme, not yours). */
+ pairing: { deeplinkScheme: string };
+ /** People-chain genesis hash (identity lookup), `0x`-prefixed hex. */
+ people: { genesisHash: string };
+ /** Bulletin-chain genesis hash (in-core preimage submission). */
+ bulletin: { genesisHash: string };
+ /**
+ * Chains this host serves, keyed by genesis hash. Also answers the core's
+ * `featureSupported` probes.
+ */
+ chains: ChainEndpoints;
+ /** Directory for the host's persistent state. Files are created 0600. */
+ storageDir: string;
+ /** Defaults to the current OS and node version. */
+ platform?: { type?: string; version?: string };
+ /** Rendering/prompting surface. Defaults to a stderr terminal presenter. */
+ presenter?: HostPresenter;
+ theme?: "Dark" | "Light";
+ /**
+ * Core `tracing` verbosity. Defaults to `warn` because the core's
+ * SSO-timeout diagnosis is ONLY visible at `warn` or above today.
+ */
+ logLevel?: string;
+ /** Optional preimage retrieval backend. Default: always a miss. */
+ lookupPreimage?: (key: Uint8Array) => Promise;
+ /** Leases per pooled socket; see chain-pool. */
+ maxLeasesPerSocket?: number;
+ /** Override the `@parity/truapi-host` dist directory (exotic setups). */
+ wasmDir?: string;
+ /** Diagnostics sink for non-user-facing host events. */
+ log?: (line: string) => void;
+}
+
+export interface CliHostProduct {
+ productId: string;
+ /**
+ * The product side of the wire. Feed it to `@parity/truapi`'s
+ * `createTransport`/`createClient` (or product-sdk's injection seam).
+ */
+ provider: WireProvider;
+ /** Core-owned logout via this product runtime; clears product storage. */
+ disconnectSession(): Promise;
+ dispose(): void;
+}
+
+export interface CliHost {
+ /** Instantiate one product core over an in-process loopback wire. */
+ createProduct(options: { productId: string }): CliHostProduct;
+ /** Cancel an in-flight pairing (the user gave up on the QR). */
+ cancelPairing(): void;
+ /** Core-owned logout, then clears product storage (keys carry no account). */
+ disconnectSession(): Promise;
+ /** Tell the core the persisted auth-session blob may have changed. */
+ notifySessionStoreChanged(): void;
+ /**
+ * The last auth state the core emitted, or `undefined` before the first
+ * emission. The core emits NOTHING at boot when unauthenticated. Render
+ * logged-out from absence, do not wait for a state.
+ */
+ authState(): AuthState | undefined;
+ onAuthState(listener: (state: AuthState) => void): () => void;
+ waitForAuthState(
+ predicate: (state: AuthState) => boolean,
+ timeoutMs?: number,
+ ): Promise;
+ /**
+ * Wipe core-namespaced product storage. Called automatically on logout and
+ * when a DIFFERENT identity connects (product-storage keys are scoped by
+ * product id only, so the next identity would inherit the previous one's
+ * data).
+ */
+ clearProductStorage(): Promise;
+ storagePaths: { core: string; product: string };
+ dispose(): void;
+}
+
+function defaultPlatformType(): string {
+ switch (process.platform) {
+ case "darwin":
+ return "macOS";
+ case "linux":
+ return "Linux";
+ case "win32":
+ return "Windows";
+ default:
+ return process.platform;
+ }
+}
+
+const LAST_IDENTITY_KEY = "lastConnectedIdentity";
+
+export async function createCliHost(config: CliHostConfig): Promise {
+ const core = await loadWasmCore({ wasmDir: config.wasmDir });
+ core.bindings.setLogLevel(config.logLevel ?? "warn");
+
+ const coreStore = new FileKeyValueStore(
+ join(config.storageDir, "core-storage.json"),
+ );
+ const productStore = new FileKeyValueStore(
+ join(config.storageDir, "product-storage.json"),
+ );
+ // Host-private bookkeeping (NOT a core storage slot): which identity last
+ // connected, so a crash between logout and login still cannot leak one
+ // identity's product storage to the next.
+ const metaStore = new FileKeyValueStore(
+ join(config.storageDir, "host-meta.json"),
+ );
+
+ const pool = createChainPool({
+ endpoints: config.chains,
+ maxLeasesPerSocket: config.maxLeasesPerSocket,
+ log: config.log,
+ });
+
+ const ownsPresenter = config.presenter === undefined;
+ const presenter = config.presenter ?? createTerminalPresenter();
+
+ let lastAuthState: AuthState | undefined;
+ const authListeners = new Set<(state: AuthState) => void>();
+ // Identity checks are async but auth emissions are ordered. Serialize the
+ // reactions so a Connected/Disconnected flurry cannot interleave clears.
+ let identityChain: Promise = Promise.resolve();
+
+ const dispatchAuthState = (state: AuthState): void => {
+ lastAuthState = state;
+ if (state.tag === "Connected") {
+ const identity = toHex(state.value.publicKey);
+ identityChain = identityChain
+ .catch(() => {})
+ .then(async () => {
+ const previous = await metaStore.get(LAST_IDENTITY_KEY);
+ if (previous !== null && previous !== identity) {
+ config.log?.(
+ "different identity connected; clearing product storage",
+ );
+ await productStore.clear();
+ }
+ await metaStore.set(LAST_IDENTITY_KEY, identity);
+ });
+ }
+ presenter.authStateChanged(state);
+ for (const listener of [...authListeners]) {
+ listener(state);
+ }
+ };
+
+ const callbacks = createHostCallbacks({
+ coreStore,
+ productStore,
+ pool,
+ presenter,
+ endpoints: config.chains,
+ theme: config.theme ?? "Dark",
+ lookupPreimage: config.lookupPreimage,
+ onAuthState: dispatchAuthState,
+ log: config.log,
+ });
+
+ const runtime = new core.bindings.WasmPairingHostRuntime(
+ core.createRawCallbacks(callbacks),
+ {
+ host: config.host,
+ platform: {
+ type: config.platform?.type ?? defaultPlatformType(),
+ version: config.platform?.version ?? process.version,
+ },
+ people: { genesisHash: config.people.genesisHash },
+ bulletin: { genesisHash: config.bulletin.genesisHash },
+ pairing: { deeplinkScheme: config.pairing.deeplinkScheme },
+ },
+ );
+
+ const products = new Set();
+ let disposed = false;
+
+ const clearProductStorage = async (): Promise => {
+ await productStore.clear();
+ };
+
+ return {
+ createProduct({ productId }) {
+ const { provider, core: productCore } = createLoopbackProvider(
+ (coreCallbacks) => runtime.productRuntime({ productId }, coreCallbacks),
+ {
+ onReceiveError: (error) => {
+ config.log?.(`receiveFrame(${productId}) failed: ${String(error)}`);
+ },
+ },
+ );
+ const product: CliHostProduct = {
+ productId,
+ provider,
+ async disconnectSession() {
+ await productCore.disconnectSession();
+ await clearProductStorage();
+ },
+ dispose() {
+ products.delete(product);
+ provider.dispose();
+ productCore.dispose();
+ },
+ };
+ products.add(product);
+ return product;
+ },
+
+ cancelPairing() {
+ runtime.cancelPairing();
+ },
+
+ async disconnectSession() {
+ await runtime.disconnectSession();
+ await clearProductStorage();
+ },
+
+ notifySessionStoreChanged() {
+ runtime.notifySessionStoreChanged();
+ },
+
+ authState() {
+ return lastAuthState;
+ },
+
+ onAuthState(listener) {
+ authListeners.add(listener);
+ return () => authListeners.delete(listener);
+ },
+
+ waitForAuthState(predicate, timeoutMs) {
+ if (lastAuthState !== undefined && predicate(lastAuthState)) {
+ return Promise.resolve(lastAuthState);
+ }
+ return new Promise((resolve, reject) => {
+ const timer =
+ timeoutMs === undefined
+ ? undefined
+ : setTimeout(() => {
+ authListeners.delete(listener);
+ reject(
+ new Error(
+ `timed out after ${String(timeoutMs)}ms waiting for an auth state`,
+ ),
+ );
+ }, timeoutMs);
+ const listener = (state: AuthState): void => {
+ if (!predicate(state)) {
+ return;
+ }
+ authListeners.delete(listener);
+ if (timer !== undefined) {
+ clearTimeout(timer);
+ }
+ resolve(state);
+ };
+ authListeners.add(listener);
+ });
+ },
+
+ clearProductStorage,
+
+ storagePaths: {
+ core: coreStore.filePath,
+ product: productStore.filePath,
+ },
+
+ dispose() {
+ if (disposed) {
+ return;
+ }
+ disposed = true;
+ for (const product of [...products]) {
+ product.dispose();
+ }
+ runtime.free();
+ pool.closeAll();
+ if (ownsPresenter) {
+ presenter.dispose();
+ }
+ },
+ };
+}
diff --git a/packages/host-cli/src/index.ts b/packages/host-cli/src/index.ts
new file mode 100644
index 00000000..8cc5ef61
--- /dev/null
+++ b/packages/host-cli/src/index.ts
@@ -0,0 +1,53 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// @dotli/host-cli, the terminal host for the TrUAPI Rust/WASM core.
+//
+// A peer of dotli's web host, not a layer on top of it: both depend directly
+// on `@parity/truapi-host` (the host engine) and implement its 17 platform
+// callbacks. The web host answers with modals and localStorage. This one
+// answers with a pairing QR, readline prompts, owner-only files, and pooled
+// sockets.
+
+export {
+ createCliHost,
+ type CliHost,
+ type CliHostConfig,
+ type CliHostProduct,
+} from "./host.js";
+export {
+ createTerminalPresenter,
+ type HostPresenter,
+ type TerminalPresenterOptions,
+} from "./presenter.js";
+export { describeReview, type ConfirmRequest } from "./reviews.js";
+export {
+ createChainPool,
+ type ChainEndpoint,
+ type ChainEndpoints,
+ type ChainPool,
+ type ChainPoolOptions,
+ type SocketLike,
+} from "./chain-pool.js";
+export {
+ FileKeyValueStore,
+ InMemoryKeyValueStore,
+ type KeyValueStore,
+} from "./kv.js";
+export {
+ createLoopbackProvider,
+ type CoreWireCallbacks,
+ type FrameReceiver,
+} from "./loopback.js";
+export {
+ createHostCallbacks,
+ coreSlot,
+ type HostCallbackDeps,
+} from "./callbacks.js";
+export {
+ serializeOperationStarts,
+ type JsonRpcProvider,
+} from "./operation-order.js";
+export { explainProductError, isProbableSsoTimeout } from "./errors.js";
+export { renderQrTerminal } from "./qr.js";
+export { loadWasmCore, type WasmCore } from "./wasm.js";
diff --git a/packages/host-cli/src/kv.ts b/packages/host-cli/src/kv.ts
new file mode 100644
index 00000000..4b2ab7df
--- /dev/null
+++ b/packages/host-cli/src/kv.ts
@@ -0,0 +1,184 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// Node KeyValueStore implementations backing the core's storage callbacks.
+// Lifted from the earlier @dotli/host-node package (feat/host-core lineage),
+// which hardened them against racing writes and world-readable files.
+
+import { readFile, writeFile, mkdir, chmod, rm } from "node:fs/promises";
+import { dirname } from "node:path";
+
+/** Plain async string KV. Values here are hex-encoded core payloads. */
+export interface KeyValueStore {
+ get(key: string): Promise;
+ set(key: string, value: string): Promise;
+ delete(key: string): Promise;
+ keys(prefix: string): Promise;
+}
+
+/** Ephemeral, process-lifetime store. Good for CLI one-shots and tests. */
+export class InMemoryKeyValueStore implements KeyValueStore {
+ private readonly map = new Map();
+
+ async get(key: string): Promise {
+ return this.map.get(key) ?? null;
+ }
+
+ async set(key: string, value: string): Promise {
+ this.map.set(key, value);
+ }
+
+ async delete(key: string): Promise {
+ this.map.delete(key);
+ }
+
+ async keys(prefix: string): Promise {
+ return [...this.map.keys()].filter((k) => k.startsWith(prefix));
+ }
+}
+
+/**
+ * Permissions for the store and its directory: owner-only.
+ *
+ * The core routes a product's `localStorage` straight into this store, and the
+ * core-storage file holds the SSO session blob and allowance keys. Default
+ * permissions would make that 0644, i.e. readable by every other local user on
+ * a shared build machine or CI box.
+ */
+const STORE_MODE = 0o600;
+const STORE_DIR_MODE = 0o700;
+
+/**
+ * Persistent store backed by a single JSON file. Loaded once on first access
+ * and written back on every mutation. Suitable for a long-lived CLI. For heavy
+ * write loads prefer a real embedded database.
+ *
+ * The file is owner-only (see {@link STORE_MODE}). Note the store is keyed by
+ * whatever prefixes the caller uses. The core scopes product storage by
+ * product id, NOT by account or session, so a consumer that supports
+ * switching identities must clear the file itself on logout, or the next
+ * identity inherits the previous one's host storage. The path is available as
+ * {@link FileKeyValueStore.filePath} for exactly that.
+ */
+export class FileKeyValueStore implements KeyValueStore {
+ private cache: Record | null = null;
+ // The IN-FLIGHT load, memoised. Serializing only the write chain was not enough:
+ // two concurrent set() calls both found `cache === null`, both issued their own
+ // readFile, and the second assignment discarded the first caller's mutation. One
+ // key vanished from the file and from memory while BOTH writes resolved
+ // successfully. Sharing the read fixes it, since after it settles every caller
+ // mutates the same object.
+ private loading: Promise> | null = null;
+ private readonly path: string;
+ // Serialize writes so two overlapping flushes can never tear the file.
+ private writeChain: Promise = Promise.resolve();
+
+ constructor(path: string) {
+ this.path = path;
+ }
+
+ /**
+ * Where this store persists, so a consumer can clear it on logout. The
+ * core scopes entries by product id, not by account, so leaving the file
+ * behind hands the next identity the previous one's host storage.
+ */
+ get filePath(): string {
+ return this.path;
+ }
+
+ private load(): Promise> {
+ if (this.cache !== null) {
+ return Promise.resolve(this.cache);
+ }
+ if (this.loading !== null) {
+ return this.loading;
+ }
+ this.loading = (async () => {
+ try {
+ this.cache = JSON.parse(await readFile(this.path, "utf8")) as Record<
+ string,
+ string
+ >;
+ } catch {
+ // Missing or unreadable file starts an empty store. A malformed file is
+ // treated the same so a corrupt cache never wedges the host.
+ this.cache = {};
+ } finally {
+ this.loading = null;
+ }
+ return this.cache;
+ })();
+ return this.loading;
+ }
+
+ private flush(): Promise {
+ // Chain each write after the previous one. The `.catch` keeps a failed
+ // write from poisoning the chain for later writes.
+ this.writeChain = this.writeChain
+ .catch(() => {})
+ .then(async () => {
+ await mkdir(dirname(this.path), {
+ recursive: true,
+ mode: STORE_DIR_MODE,
+ });
+ await writeFile(this.path, JSON.stringify(this.cache ?? {}), {
+ encoding: "utf8",
+ mode: STORE_MODE,
+ });
+ // `mode` on writeFile only applies when the file is CREATED, so a store
+ // written before this was tightened would stay 0644 forever. chmod every
+ // flush to self-heal those.
+ await chmod(this.path, STORE_MODE).catch(() => {
+ // Not our file, or a filesystem without POSIX modes. The write itself
+ // succeeded, so don't fail the store over its permissions.
+ });
+ });
+ return this.writeChain;
+ }
+
+ // Mutations go through `this.cache` AFTER the load settles, never through
+ // the object `load()` resolved with. `clear()` swaps the cache object out,
+ // so a mutation on the resolved reference could land on a detached object
+ // and a successfully-resolved write would silently vanish.
+ async get(key: string): Promise {
+ await this.load();
+ return (this.cache ??= {})[key] ?? null;
+ }
+
+ async set(key: string, value: string): Promise {
+ await this.load();
+ (this.cache ??= {})[key] = value;
+ await this.flush();
+ }
+
+ async delete(key: string): Promise {
+ await this.load();
+ delete (this.cache ??= {})[key];
+ await this.flush();
+ }
+
+ async keys(prefix: string): Promise {
+ await this.load();
+ return Object.keys((this.cache ??= {})).filter((k) => k.startsWith(prefix));
+ }
+
+ /**
+ * Drop every entry and delete the backing file. The next identity to log in
+ * must not inherit this one's storage (core product-storage keys carry no
+ * account component), so logout calls this.
+ */
+ async clear(): Promise {
+ // Let an in-flight load settle first. Its `this.cache = ...` assignment
+ // would otherwise resurrect stale data over the cleared cache.
+ if (this.loading !== null) {
+ await this.loading;
+ }
+ this.cache = {};
+ // Ride the write chain so an in-flight flush cannot resurrect the file
+ // after the removal.
+ this.writeChain = this.writeChain
+ .catch(() => {})
+ .then(() => rm(this.path, { force: true }));
+ await this.writeChain;
+ }
+}
diff --git a/packages/host-cli/src/loopback.ts b/packages/host-cli/src/loopback.ts
new file mode 100644
index 00000000..fd94012e
--- /dev/null
+++ b/packages/host-cli/src/loopback.ts
@@ -0,0 +1,60 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// The loopback wire: product client <-> core, in one process.
+//
+// This is the structural difference from the web host: no iframe, no
+// postMessage, no separate origin. The product and the core share a process,
+// so the wire is a plain in-memory pipe.
+
+import type { WireProvider } from "@parity/truapi-host";
+
+/** The callbacks the core hands its frames to. */
+export interface CoreWireCallbacks {
+ emitFrame(frame: Uint8Array): void;
+ dispose(): void;
+}
+
+/** The slice of a product core the loopback needs. */
+export interface FrameReceiver {
+ receiveFrame(frame: Uint8Array): Promise;
+}
+
+/**
+ * Wire a product core to a `WireProvider` over an in-process pipe.
+ *
+ * `receiveFrame` is invoked SYNCHRONOUSLY per posted frame so frames enter the
+ * core in post order (the tier-2 spike measured a pipe with its own queueing
+ * manufacturing the exact operation-ordering inversion under test). Its
+ * returned promise is only observed for errors. A frame like `requestLogin`
+ * can stay pending for minutes, so completion must not gate later frames.
+ */
+export function createLoopbackProvider(
+ makeCore: (callbacks: CoreWireCallbacks) => C,
+ options: { onReceiveError?: (error: unknown) => void } = {},
+): { provider: WireProvider; core: C } {
+ const subscribers = new Set<(frame: Uint8Array) => void>();
+ const core = makeCore({
+ emitFrame(frame) {
+ for (const callback of [...subscribers]) {
+ callback(frame);
+ }
+ },
+ dispose() {},
+ });
+ const provider: WireProvider = {
+ postMessage(frame: Uint8Array): void {
+ core.receiveFrame(frame).catch((error: unknown) => {
+ options.onReceiveError?.(error);
+ });
+ },
+ subscribe(callback) {
+ subscribers.add(callback);
+ return () => subscribers.delete(callback);
+ },
+ dispose() {
+ subscribers.clear();
+ },
+ };
+ return { provider, core };
+}
diff --git a/packages/host-cli/src/operation-order.ts b/packages/host-cli/src/operation-order.ts
new file mode 100644
index 00000000..935ff432
--- /dev/null
+++ b/packages/host-cli/src/operation-order.ts
@@ -0,0 +1,159 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// WORKAROUND for a causal-ordering hazard in the chain-head relay, measured
+// to SURVIVE the Rust core (its TS-host-era header blamed a package that is
+// deleted, yet the identical inversion reproduces).
+//
+// papi learns a chain-head operation's `operationId` ONLY from the
+// `chainHead_v1_storage`/`_body`/`_call` start-response
+// (`{result:{result:"started",operationId}}`): substrate-client registers the
+// per-operation subscriber inside that response's `onSuccess`, then routes
+// events through a manager whose dispatch is
+//
+// next(id, data) { subscriptions.get(id)?.next(data) }
+//
+// so an operation event that arrives BEFORE its start-response is dropped on
+// the floor (silently, thanks to the `?.`) and the read it belongs to never
+// settles. Measured against the core (tier 8): papi hung 3 runs in 6 with a
+// 155-request/12s retry storm, and inversion count correlated with the hang
+// 6/6. With this shim wrapping the provider: 6/6 clean.
+//
+// The shim restores the invariant on the consumer side, where it is cheap and
+// transport-agnostic: hold back events whose `operationId` has not been
+// announced yet, and release them the moment it is. It belongs on the PRODUCT
+// side, wrapping the papi `JsonRpcProvider` that speaks to the core.
+
+/**
+ * Structurally compatible with polkadot-api's `JsonRpcProvider` (which passes
+ * parsed message objects), without making papi a dependency of this package.
+ */
+export type JsonRpcProvider = (onMessage: (message: unknown) => void) => {
+ send: (message: unknown) => void;
+ disconnect: () => void;
+};
+
+type RpcMessage = unknown;
+
+/** Operation events after which an `operationId` is retired by the spec. */
+const TERMINAL_EVENTS = new Set([
+ "operationBodyDone",
+ "operationCallDone",
+ "operationStorageDone",
+ "operationError",
+ "operationInaccessible",
+]);
+
+/** The `operationId` a start-response announces, if this message is one. */
+function announcedOperationId(message: RpcMessage): string | undefined {
+ const result = (message as { result?: unknown }).result;
+ if (result === null || typeof result !== "object") return undefined;
+ const started = result as { result?: unknown; operationId?: unknown };
+ return started.result === "started" && typeof started.operationId === "string"
+ ? started.operationId
+ : undefined;
+}
+
+/** The follow-event payload, if this message is a `chainHead_v1_followEvent`. */
+function followEvent(
+ message: RpcMessage,
+): { event?: unknown; operationId?: unknown } | undefined {
+ if ((message as { method?: unknown }).method !== "chainHead_v1_followEvent") {
+ return undefined;
+ }
+ return (
+ message as {
+ params?: { result?: { event?: unknown; operationId?: unknown } };
+ }
+ ).params?.result;
+}
+
+/**
+ * Wrap a `JsonRpcProvider` so a chain-head operation's events can never reach
+ * the consumer before the start-response that names the operation. Messages
+ * without an `operationId` (`initialized`, `newBlock`, `finalized`, plain
+ * responses) pass straight through, so ordering is only ever adjusted where
+ * it is load-bearing.
+ */
+export function serializeOperationStarts(
+ provider: JsonRpcProvider,
+): JsonRpcProvider {
+ return (onMessage) => {
+ const announced = new Set();
+ const queued = new Map();
+ // Whether a follow is currently live. After a `stop`, papi's
+ // substrate-client has torn the follow down and rejects any operation it
+ // is told about (`onSubscribeOperation` errors with DisjointError while
+ // followSubscription is null), so a start-response still in flight from
+ // the dead follow registers NO subscriber. Announcing its id anyway would
+ // un-gate the NEXT operation that reuses that number (substrate hands
+ // out small per-follow counters, so the refollow's first storage op is
+ // very likely to be "1" again) and papi would silently drop its events.
+ // That is precisely the stall this shim exists to prevent. `initialized`
+ // is the first event of every follow, so it marks the point from which
+ // start-responses can be trusted again. Starts true: the opening follow
+ // has not been stopped.
+ let followLive = true;
+
+ return provider((message) => {
+ const startedId = announcedOperationId(message);
+ if (startedId !== undefined) {
+ // Forward it regardless (papi decides what to do with it), but only
+ // treat it as an announcement while a follow is live.
+ onMessage(message);
+ if (!followLive) return;
+ announced.add(startedId);
+ const pending = queued.get(startedId);
+ if (pending !== undefined) {
+ queued.delete(startedId);
+ for (const event of pending) onMessage(event);
+ }
+ return;
+ }
+
+ const event = followEvent(message);
+ if (event === undefined) {
+ onMessage(message);
+ return;
+ }
+
+ // A `stop` ends the follow, so every operation under it is dead: drop
+ // the bookkeeping (and any still-unannounced events, which can never be
+ // delivered) so a refollow starts clean.
+ if (event.event === "stop") {
+ followLive = false;
+ announced.clear();
+ queued.clear();
+ onMessage(message);
+ return;
+ }
+
+ const operationId = event.operationId;
+ if (typeof operationId !== "string") {
+ // Any non-operation follow event (`initialized`, `newBlock`,
+ // `bestBlockChanged`, `finalized`) can only come from a LIVE follow,
+ // so it ends the post-stop dead zone. Keyed on the whole class rather
+ // than `initialized` alone: if a refollow's `initialized` were ever
+ // missed, start-responses would stop being honoured and every
+ // operation would queue forever, trading one hang for another.
+ followLive = true;
+ onMessage(message);
+ return;
+ }
+
+ if (!announced.has(operationId)) {
+ const pending = queued.get(operationId);
+ if (pending === undefined) queued.set(operationId, [message]);
+ else pending.push(message);
+ return;
+ }
+
+ onMessage(message);
+ // Retiring the id on its terminal event keeps both maps bounded over a
+ // long-lived client instead of growing once per operation.
+ if (typeof event.event === "string" && TERMINAL_EVENTS.has(event.event)) {
+ announced.delete(operationId);
+ }
+ });
+ };
+}
diff --git a/packages/host-cli/src/presenter.ts b/packages/host-cli/src/presenter.ts
new file mode 100644
index 00000000..1b867c23
--- /dev/null
+++ b/packages/host-cli/src/presenter.ts
@@ -0,0 +1,183 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// The terminal presentation layer. A host IS the UI layer: the web host
+// answers the core with modals, this one answers with a QR and readline
+// prompts. Everything is swappable behind `HostPresenter` so an embedding CLI
+// can restyle without re-wiring callbacks (and tests can script decisions).
+
+import * as readline from "node:readline/promises";
+import type { AuthState } from "@parity/truapi-host";
+import { shortHex } from "./hex.js";
+import { renderQrTerminal } from "./qr.js";
+import type { ConfirmRequest } from "./reviews.js";
+
+export interface HostPresenter {
+ /** Render the core-owned auth lifecycle (QR, progress, session identity). */
+ authStateChanged(state: AuthState): void;
+ /** Ask the user to approve a reviewed action or permission. */
+ confirm(request: ConfirmRequest): Promise;
+ /** Show a product notification. */
+ notify(text: string): void;
+ /** Hand a URL to the user (the CLI cannot assume a system browser). */
+ openUrl(url: string): void;
+ dispose(): void;
+}
+
+export interface TerminalPresenterOptions {
+ /** Where to render. Defaults to stderr so piped stdout stays clean. */
+ output?: NodeJS.WriteStream;
+ /** Where confirm prompts read from. Defaults to stdin. */
+ input?: NodeJS.ReadStream;
+ /** Override the QR renderer (tests, exotic terminals). */
+ renderQr?: (deeplink: string) => Promise;
+}
+
+export function createTerminalPresenter(
+ options: TerminalPresenterOptions = {},
+): HostPresenter {
+ const output = options.output ?? process.stderr;
+ const input = options.input ?? process.stdin;
+ const renderQr = options.renderQr ?? renderQrTerminal;
+ const write = (text: string): void => {
+ output.write(text);
+ };
+
+ let progressTimer: NodeJS.Timeout | null = null;
+ let disposed = false;
+ // Confirm prompts share one stdin. Serialize them so two overlapping
+ // reviews can never interleave their answers.
+ let promptChain: Promise = Promise.resolve();
+
+ const clearProgress = (): void => {
+ if (progressTimer !== null) {
+ clearInterval(progressTimer);
+ progressTimer = null;
+ if (output.isTTY) {
+ write("\n");
+ }
+ }
+ };
+
+ const startProgress = (label: string): void => {
+ clearProgress();
+ const startedAt = Date.now();
+ if (!output.isTTY) {
+ write(`${label}\n`);
+ return;
+ }
+ write(label);
+ progressTimer = setInterval(() => {
+ const elapsed = Math.round((Date.now() - startedAt) / 1000);
+ write(`\r${label} ${String(elapsed)}s`);
+ }, 1000);
+ // A progress line must never be the thing keeping the process alive.
+ progressTimer.unref();
+ };
+
+ return {
+ authStateChanged(state) {
+ if (disposed) {
+ return;
+ }
+ switch (state.tag) {
+ case "Pairing": {
+ const { deeplink } = state.value;
+ void renderQr(deeplink).then(
+ (qr) => {
+ if (disposed) {
+ return;
+ }
+ write(
+ `\nScan with the Polkadot app to sign in:\n\n${qr}\n` +
+ `Or open this link on your phone:\n ${deeplink}\n\n`,
+ );
+ },
+ () => {
+ // A QR that fails to render must not hide the deeplink.
+ write(
+ `\nOpen this link on your phone to sign in:\n ${deeplink}\n\n`,
+ );
+ },
+ );
+ break;
+ }
+ case "Authenticating":
+ // The People-chain statement round-trip runs ~20s with no further
+ // callback. Without this line the host looks hung on a stale QR.
+ startProgress(
+ "Confirmed on your phone. Completing sign-in (about 20 seconds)…",
+ );
+ break;
+ case "Connected": {
+ clearProgress();
+ const { publicKey, fullUsername, liteUsername } = state.value;
+ const username = fullUsername ?? liteUsername;
+ write(
+ `Signed in${username !== undefined ? ` as ${username}` : ""} (${shortHex(publicKey)}).\n`,
+ );
+ break;
+ }
+ case "LoginFailed":
+ clearProgress();
+ write(`Sign-in failed: ${state.value.reason}\n`);
+ break;
+ case "Disconnected":
+ clearProgress();
+ write("Signed out.\n");
+ break;
+ }
+ },
+
+ confirm(request) {
+ const decision = promptChain.then(async () => {
+ if (disposed) {
+ return false;
+ }
+ clearProgress();
+ const lines = [
+ "",
+ `▸ ${request.title}`,
+ ...request.details.map((detail) => ` ${detail}`),
+ ];
+ if (request.phoneVerifies) {
+ lines.push(
+ " Verify the full details in the Polkadot app on your phone.",
+ " Nothing is signed until you approve it there.",
+ );
+ }
+ write(`${lines.join("\n")}\n`);
+ if (!input.isTTY) {
+ // A host that cannot ask must not approve.
+ write(" No interactive terminal, denying automatically.\n");
+ return false;
+ }
+ const rl = readline.createInterface({ input, output });
+ try {
+ const answer = await rl.question(" Continue? [y/N] ");
+ return /^y(es)?$/i.test(answer.trim());
+ } finally {
+ rl.close();
+ }
+ });
+ promptChain = decision.then(
+ () => {},
+ () => {},
+ );
+ return decision;
+ },
+
+ notify(text) {
+ write(`• ${text}\n`);
+ },
+
+ openUrl(url) {
+ write(`Open this link in your browser:\n ${url}\n`);
+ },
+
+ dispose() {
+ disposed = true;
+ clearProgress();
+ },
+ };
+}
diff --git a/packages/host-cli/src/qr.ts b/packages/host-cli/src/qr.ts
new file mode 100644
index 00000000..82877ca3
--- /dev/null
+++ b/packages/host-cli/src/qr.ts
@@ -0,0 +1,13 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import * as QRCode from "qrcode";
+
+/**
+ * Render a pairing deeplink as a scannable terminal QR (ANSI half-blocks).
+ * The deeplink is produced by the core with no network at all, so this can be
+ * drawn instantly and offline.
+ */
+export function renderQrTerminal(text: string): Promise {
+ return QRCode.toString(text, { type: "terminal", small: true });
+}
diff --git a/packages/host-cli/src/reviews.ts b/packages/host-cli/src/reviews.ts
new file mode 100644
index 00000000..78d29dbf
--- /dev/null
+++ b/packages/host-cli/src/reviews.ts
@@ -0,0 +1,149 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// Confirm-prompt content for the core's review surface.
+//
+// The prompts are DELIBERATELY modest. The host cannot decode what it is
+// approving: `CreateTransaction` carries `callData` as opaque hex without a
+// method name, a readable address, or arguments, and `PreimageSubmit`
+// carries only a byte count. The paired wallet is the authoritative trust
+// surface. It decodes and displays the real content before anything is
+// signed. So `confirmUserAction` is a local pre-confirmation, and these
+// prompts render only what the host knows on TYPED authority (review kind,
+// account, chain, sizes) and defer the content to the phone rather than
+// pretending to summarise bytes they cannot decode.
+
+import type { UserConfirmationReview } from "@parity/truapi-host";
+import type { ChainEndpoints } from "./chain-pool.js";
+import { hexByteLength } from "./hex.js";
+
+/** What a presenter is asked to confirm. */
+export interface ConfirmRequest {
+ /** One-line action statement, e.g. `Sign a message`. */
+ title: string;
+ /** Typed metadata the host knows on its own authority. */
+ details: string[];
+ /**
+ * Whether the paired wallet will show the authoritative content before
+ * signing. When true, presenters should tell the user to check the phone.
+ */
+ phoneVerifies: boolean;
+}
+
+function chainName(
+ endpoints: ChainEndpoints | undefined,
+ genesisHash: string,
+): string {
+ const name = endpoints?.[genesisHash]?.name;
+ return name !== undefined ? name : `chain ${genesisHash.slice(0, 10)}…`;
+}
+
+function accountLine(account: {
+ dotNsIdentifier: string;
+ derivationIndex: number;
+}): string {
+ return `account: ${account.dotNsIdentifier} (derivation #${String(account.derivationIndex)})`;
+}
+
+/** The wallet decodes legacy-account requests too. The host knows even less. */
+function legacyAccountReview(title: string): ConfirmRequest {
+ return { title, details: [], phoneVerifies: true };
+}
+
+/**
+ * Describe a review for a terminal confirm prompt.
+ *
+ * `endpoints` (optional) resolves genesis hashes to human-readable chain
+ * names in transaction prompts.
+ */
+export function describeReview(
+ review: UserConfirmationReview,
+ options: { endpoints?: ChainEndpoints } = {},
+): ConfirmRequest {
+ switch (review.tag) {
+ case "SignRaw": {
+ if (review.value.tag !== "Product") {
+ return legacyAccountReview("Sign a message with a legacy account");
+ }
+ const { account, payload } = review.value.value;
+ // The RawPayload discriminant survives into the review: `Bytes` is
+ // raw binary, `Payload` is a wrapped string message. Rendering the
+ // distinction matters. Raw bytes could be anything, including a
+ // transaction-shaped payload.
+ const kind =
+ payload.tag === "Bytes"
+ ? `raw binary data (${String(hexByteLength(payload.value.bytes))} bytes)`
+ : "a text message";
+ return {
+ title: "Sign a message",
+ details: [accountLine(account), `payload: ${kind}`],
+ phoneVerifies: true,
+ };
+ }
+ case "SignPayload": {
+ if (review.value.tag !== "Product") {
+ return legacyAccountReview("Sign a payload with a legacy account");
+ }
+ return { title: "Sign a payload", details: [], phoneVerifies: true };
+ }
+ case "CreateTransaction": {
+ if (review.value.tag !== "Product") {
+ return legacyAccountReview(
+ "Submit a transaction with a legacy account",
+ );
+ }
+ const { signer, genesisHash, callData } = review.value.value;
+ return {
+ title: `Submit a transaction on ${chainName(options.endpoints, genesisHash)}`,
+ details: [
+ accountLine(signer),
+ `call data: ${String(hexByteLength(callData))} bytes (not decodable here)`,
+ ],
+ phoneVerifies: true,
+ };
+ }
+ case "ResourceAllocation":
+ return {
+ title: "Allocate network resources",
+ details: review.value.resources.map(
+ (resource) => `resource: ${JSON.stringify(resource)}`,
+ ),
+ phoneVerifies: true,
+ };
+ case "PreimageSubmit":
+ return {
+ title: "Publish data to the Bulletin chain",
+ details: [
+ `size: ${String(review.value.size)} bytes (content not decodable here)`,
+ ],
+ phoneVerifies: true,
+ };
+ case "AccountAlias":
+ return {
+ title: "Derive a contextual alias",
+ details: [`product: ${review.value.callingProductId}`],
+ phoneVerifies: false,
+ };
+ case "CreateProof":
+ return {
+ title: "Create a ring-VRF proof",
+ details: [`product: ${review.value.callingProductId}`],
+ phoneVerifies: false,
+ };
+ case "IdentityDisclosure":
+ return {
+ title: "Disclose your primary identity to a product",
+ details: [`product: ${review.value.productId}`],
+ phoneVerifies: false,
+ };
+ case "AccountAccess":
+ return {
+ title: "Allow one product to access another product's account",
+ details: [
+ `requesting: ${review.value.requestingProductId}`,
+ `target: ${review.value.targetProductId}`,
+ ],
+ phoneVerifies: false,
+ };
+ }
+}
diff --git a/packages/host-cli/src/wasm.ts b/packages/host-cli/src/wasm.ts
new file mode 100644
index 00000000..cf1ff1f2
--- /dev/null
+++ b/packages/host-cli/src/wasm.ts
@@ -0,0 +1,103 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// Boots the Rust core under plain node: no Worker, no DOM, no fetch.
+//
+// `@parity/truapi-host` ships a `wasm-bindgen --target web` build, but the
+// browser coupling lives in the wrappers, not the core. `initSync({module})`
+// instantiates the wasm from a plain Buffer, and the generated typed-to-raw
+// callback adapter (`createWasmRawCallbacks`) references nothing beyond plain
+// JS. The adapter is NOT in the package's exports map, so it is reached by
+// file URL relative to the one wasm path that IS exported. This is an
+// upstream packaging ask: a `./node` export would delete this file's second
+// half.
+
+import { existsSync, readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import type { RequiredHostCallbacks } from "@parity/truapi-host";
+
+type WasmBindings = typeof import("@parity/truapi-host/wasm/web");
+
+export interface WasmCore {
+ bindings: WasmBindings;
+ /** The package's own generated typed-to-raw adapter, reached by file URL. */
+ createRawCallbacks: (callbacks: RequiredHostCallbacks) => unknown;
+}
+
+/**
+ * Locate `@parity/truapi-host`'s install directory. `import.meta.resolve` is
+ * the honest way. The node_modules walk covers runtimes that transform
+ * modules and lose it (vitest's vite-node, some bundlers).
+ */
+function findHostPackageDir(): string {
+ try {
+ const glue = fileURLToPath(
+ import.meta.resolve("@parity/truapi-host/wasm/web"),
+ );
+ // dist/wasm/web/truapi_server.js -> package root
+ return dirname(dirname(dirname(dirname(glue))));
+ } catch {
+ let dir = dirname(fileURLToPath(import.meta.url));
+ for (;;) {
+ const candidate = join(dir, "node_modules", "@parity", "truapi-host");
+ if (existsSync(join(candidate, "package.json"))) {
+ return candidate;
+ }
+ const parent = dirname(dir);
+ if (parent === dir) {
+ throw new Error(
+ "Could not locate @parity/truapi-host. Pass `wasmDir` (its dist/ directory) to createCliHost.",
+ );
+ }
+ dir = parent;
+ }
+ }
+}
+
+// Keyed by the resolved dist directory, not held as one process-wide
+// singleton: a caller passing a different `wasmDir` gets its own core
+// instance (module identity follows the file URL), instead of silently
+// receiving whichever core loaded first.
+const loadedByDistDir = new Map>();
+
+async function loadFrom(distDir: string): Promise {
+ const glueUrl = pathToFileURL(
+ join(distDir, "wasm", "web", "truapi_server.js"),
+ ).href;
+ const bindings = (await import(glueUrl)) as WasmBindings;
+ bindings.initSync({
+ module: readFileSync(join(distDir, "wasm", "web", "truapi_server_bg.wasm")),
+ });
+ const adapterUrl = pathToFileURL(
+ join(distDir, "generated", "host-callbacks-adapter.js"),
+ ).href;
+ const adapter = (await import(adapterUrl)) as {
+ createWasmRawCallbacks: (callbacks: RequiredHostCallbacks) => unknown;
+ };
+ return { bindings, createRawCallbacks: adapter.createWasmRawCallbacks };
+}
+
+/**
+ * Load and instantiate the wasm core, once per dist directory.
+ *
+ * The wasm holds module-level state, so everything sharing a dist directory
+ * must go through ONE instance. The glue is always imported by file URL to
+ * keep module identity stable (a bare-specifier import elsewhere would
+ * materialize a second instance).
+ */
+export function loadWasmCore(
+ options: { wasmDir?: string } = {},
+): Promise {
+ const distDir = options.wasmDir ?? join(findHostPackageDir(), "dist");
+ let pending = loadedByDistDir.get(distDir);
+ if (pending === undefined) {
+ pending = loadFrom(distDir);
+ loadedByDistDir.set(distDir, pending);
+ // A failed load (wrong wasmDir) must not cache its rejection forever.
+ pending.catch(() => {
+ loadedByDistDir.delete(distDir);
+ });
+ }
+ return pending;
+}
diff --git a/packages/host-cli/tests/chain-pool.test.ts b/packages/host-cli/tests/chain-pool.test.ts
new file mode 100644
index 00000000..07719da6
--- /dev/null
+++ b/packages/host-cli/tests/chain-pool.test.ts
@@ -0,0 +1,220 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { describe, it, expect } from "vitest";
+import { createChainPool, type SocketLike } from "../src/chain-pool.js";
+
+const GENESIS =
+ "0xc5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5";
+
+class FakeSocket implements SocketLike {
+ listeners = new Map void)[]>();
+ sent: string[] = [];
+ closed = false;
+
+ addEventListener(type: string, listener: (event: unknown) => void): void {
+ const existing = this.listeners.get(type) ?? [];
+ this.listeners.set(type, [...existing, listener]);
+ }
+
+ send(data: string): void {
+ this.sent.push(data);
+ }
+
+ close(): void {
+ this.closed = true;
+ }
+
+ emit(type: string, event: unknown = {}): void {
+ for (const listener of this.listeners.get(type) ?? []) {
+ listener(event);
+ }
+ }
+
+ receive(message: unknown): void {
+ this.emit("message", { data: JSON.stringify(message) });
+ }
+}
+
+function pool(maxLeasesPerSocket = 2) {
+ const sockets: FakeSocket[] = [];
+ const chainPool = createChainPool({
+ endpoints: { [GENESIS]: { rpc: "wss://example.test", name: "people" } },
+ maxLeasesPerSocket,
+ createSocket: () => {
+ const socket = new FakeSocket();
+ sockets.push(socket);
+ return socket;
+ },
+ });
+ return { chainPool, sockets };
+}
+
+/** Collect everything a connection's responses() stream has delivered. */
+function collect(connection: { responses(): AsyncIterable }) {
+ const received: unknown[] = [];
+ void (async () => {
+ for await (const json of connection.responses()) {
+ received.push(JSON.parse(json));
+ }
+ })();
+ return received;
+}
+
+const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+describe("createChainPool", () => {
+ it("As the wasm core, I connect to a chain with no configured endpoint and the connection is rejected", async () => {
+ // Given
+ const { chainPool } = pool();
+
+ // Then
+ await expect(chainPool.connect("0xdeadbeef")).rejects.toThrow(
+ /no RPC endpoint/,
+ );
+ });
+
+ it("As the wasm core, I send on two leases of one shared socket and each receives exactly its own response", async () => {
+ // Given
+ const { chainPool, sockets } = pool();
+ const a = await chainPool.connect(GENESIS);
+ const b = await chainPool.connect(GENESIS);
+ expect(sockets).toHaveLength(1);
+ const socket = sockets[0];
+ socket.emit("open");
+
+ // When
+ // Both leases use the same request id. The pool must keep them apart.
+ a.send(JSON.stringify({ jsonrpc: "2.0", id: "p:1", method: "m_a" }));
+ b.send(JSON.stringify({ jsonrpc: "2.0", id: "p:1", method: "m_b" }));
+ const [wireA, wireB] = socket.sent.map(
+ (json) => JSON.parse(json) as { id: string },
+ );
+ expect(wireA.id).not.toBe(wireB.id);
+
+ const seenA = collect(a);
+ const seenB = collect(b);
+ socket.receive({ jsonrpc: "2.0", id: wireB.id, result: "for-b" });
+ socket.receive({ jsonrpc: "2.0", id: wireA.id, result: "for-a" });
+ await tick();
+
+ // Then
+ // Each lease got exactly its own response, with the ORIGINAL id restored.
+ expect(seenA).toEqual([{ jsonrpc: "2.0", id: "p:1", result: "for-a" }]);
+ expect(seenB).toEqual([{ jsonrpc: "2.0", id: "p:1", result: "for-b" }]);
+ });
+
+ it("As the wasm core, I subscribe on one lease and its notifications reach only that lease", async () => {
+ // Given
+ const { chainPool, sockets } = pool();
+ const a = await chainPool.connect(GENESIS);
+ const b = await chainPool.connect(GENESIS);
+ const socket = sockets[0];
+ socket.emit("open");
+
+ // When
+ a.send(
+ JSON.stringify({ jsonrpc: "2.0", id: 1, method: "chainHead_v1_follow" }),
+ );
+ const wireId = (JSON.parse(socket.sent[0]) as { id: string }).id;
+ const seenA = collect(a);
+ const seenB = collect(b);
+ // The subscribe response names the token (a string result)...
+ socket.receive({ jsonrpc: "2.0", id: wireId, result: "sub-token" });
+ // ...and notifications for it must reach ONLY the subscribing lease.
+ socket.receive({
+ jsonrpc: "2.0",
+ method: "chainHead_v1_followEvent",
+ params: { subscription: "sub-token", result: { event: "initialized" } },
+ });
+ await tick();
+
+ // Then
+ expect(seenA).toHaveLength(2);
+ expect(seenB).toHaveLength(0);
+ });
+
+ it("As the wasm core, I connect past the lease cap and the pool opens another socket", async () => {
+ // Given
+ const { chainPool, sockets } = pool(2);
+ await chainPool.connect(GENESIS);
+ await chainPool.connect(GENESIS);
+
+ // When
+ await chainPool.connect(GENESIS);
+
+ // Then
+ expect(sockets).toHaveLength(2);
+ expect(chainPool.socketCounts()).toEqual({ "wss://example.test": 2 });
+ });
+
+ it("As the wasm core, I send before the socket opens and the message flushes on open", async () => {
+ // Given
+ const { chainPool, sockets } = pool();
+ const lease = await chainPool.connect(GENESIS);
+ lease.send(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "early" }));
+ expect(sockets[0].sent).toHaveLength(0);
+
+ // When
+ sockets[0].emit("open");
+
+ // Then
+ expect(sockets[0].sent).toHaveLength(1);
+ });
+
+ it("As a host embedder, a freed lease slot is reused and the socket stays open while any lease remains", async () => {
+ // Given
+ const { chainPool, sockets } = pool(2);
+ const a = await chainPool.connect(GENESIS);
+ const b = await chainPool.connect(GENESIS);
+
+ // When
+ a.close();
+
+ // Then
+ expect(sockets[0].closed).toBe(false);
+
+ // The freed slot is reused instead of opening socket #2.
+ await chainPool.connect(GENESIS);
+ expect(sockets).toHaveLength(1);
+
+ b.close();
+ // One lease (the reused slot) still holds it open.
+ expect(sockets[0].closed).toBe(false);
+ });
+
+ it("As the wasm core, my lease's response stream ends when the socket closes", async () => {
+ // Given
+ const { chainPool, sockets } = pool();
+ const lease = await chainPool.connect(GENESIS);
+ const iterator = lease.responses()[Symbol.asyncIterator]();
+ const first = iterator.next();
+
+ // When
+ sockets[0].emit("close");
+
+ // Then
+ expect((await first).done).toBe(true);
+ expect(chainPool.socketCounts()).toEqual({});
+ });
+
+ it("As the wasm core, responses for a closed lease are dropped instead of leaking to other leases", async () => {
+ // Given
+ const { chainPool, sockets } = pool();
+ const a = await chainPool.connect(GENESIS);
+ const b = await chainPool.connect(GENESIS);
+ const socket = sockets[0];
+ socket.emit("open");
+ a.send(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "m" }));
+ const wireId = (JSON.parse(socket.sent[0]) as { id: string }).id;
+ const seenB = collect(b);
+
+ // When
+ a.close();
+ socket.receive({ jsonrpc: "2.0", id: wireId, result: "late" });
+ await tick();
+
+ // Then
+ expect(seenB).toHaveLength(0);
+ });
+});
diff --git a/packages/host-cli/tests/errors.test.ts b/packages/host-cli/tests/errors.test.ts
new file mode 100644
index 00000000..7996036e
--- /dev/null
+++ b/packages/host-cli/tests/errors.test.ts
@@ -0,0 +1,54 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { describe, it, expect } from "vitest";
+import { explainProductError, isProbableSsoTimeout } from "../src/errors.js";
+
+describe("isProbableSsoTimeout", () => {
+ it("As a host embedder, the bare TxError the 180s SSO timeout produces is recognized as one", () => {
+ // Then
+ expect(
+ isProbableSsoTimeout({ isSdkError: true, source: "tx", name: "TxError" }),
+ ).toBe(true);
+ });
+
+ it("As a host embedder, a TxError carrying a real message is never flagged as an SSO timeout", () => {
+ // Then
+ expect(
+ isProbableSsoTimeout({
+ isSdkError: true,
+ source: "tx",
+ name: "TxError",
+ message: "insufficient funds",
+ }),
+ ).toBe(false);
+ });
+
+ it("As a host embedder, unrelated errors are never flagged as SSO timeouts", () => {
+ // Then
+ expect(isProbableSsoTimeout(new Error("boom"))).toBe(false);
+ expect(isProbableSsoTimeout(undefined)).toBe(false);
+ expect(
+ isProbableSsoTimeout({ isSdkError: true, source: "rpc", name: "X" }),
+ ).toBe(false);
+ });
+});
+
+describe("explainProductError", () => {
+ it("As a CLI user, I hit the probable SSO timeout and get phone-facing guidance", () => {
+ // When
+ const explained = explainProductError({
+ isSdkError: true,
+ source: "tx",
+ name: "TxError",
+ });
+
+ // Then
+ expect(explained).toMatch(/no response from your phone/i);
+ });
+
+ it("As a CLI user, the host stays silent for errors it cannot improve on", () => {
+ // Then
+ expect(explainProductError(new Error("boom"))).toBeUndefined();
+ });
+});
diff --git a/packages/host-cli/tests/host.test.ts b/packages/host-cli/tests/host.test.ts
new file mode 100644
index 00000000..b897039a
--- /dev/null
+++ b/packages/host-cli/tests/host.test.ts
@@ -0,0 +1,166 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// Integration: the REAL wasm core, booted in-process, driven by the real
+// product client over the loopback wire. No network, no phone:
+//
+// - a product localStorage round-trip exercises the full frame path
+// (client -> transport -> loopback -> Rust -> storage callbacks -> back).
+// - `requestLogin` exercises the pairing presentation headless. The core
+// emits `AuthState.Pairing{deeplink}` before opening any socket, so a
+// host with NO endpoints still gets a QR to render (Gate A's shape).
+// - logout clearing pins the product-storage-is-not-account-scoped rule.
+
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
+import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
+import { existsSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { createClient, createTransport } from "@parity/truapi";
+import type { AuthState } from "@parity/truapi-host";
+
+import { createCliHost, type CliHost } from "../src/host.js";
+import type { ConfirmRequest } from "../src/reviews.js";
+import type { HostPresenter } from "../src/presenter.js";
+
+/** A presenter with scripted decisions and a full recording. */
+function createScriptedPresenter(decide: (request: ConfirmRequest) => boolean) {
+ const states: AuthState[] = [];
+ const confirms: ConfirmRequest[] = [];
+ const notifications: string[] = [];
+ const presenter: HostPresenter = {
+ authStateChanged(state) {
+ states.push(state);
+ },
+ async confirm(request) {
+ confirms.push(request);
+ return decide(request);
+ },
+ notify(text) {
+ notifications.push(text);
+ },
+ openUrl() {},
+ dispose() {},
+ };
+ return { presenter, states, confirms, notifications };
+}
+
+describe("createCliHost against the real wasm core", () => {
+ let dir: string;
+ let host: CliHost;
+ let scripted: ReturnType;
+
+ beforeAll(async () => {
+ dir = await mkdtemp(join(tmpdir(), "host-cli-int-"));
+ scripted = createScriptedPresenter(() => false);
+ host = await createCliHost({
+ host: { name: "host-cli tests", version: "0.0.0-test" },
+ pairing: { deeplinkScheme: "polkadotapp" },
+ // Unreachable genesis hashes: the test must stay offline. featureSupported
+ // and chain.connect both answer from this map.
+ people: { genesisHash: `0x${"11".repeat(32)}` },
+ bulletin: { genesisHash: `0x${"22".repeat(32)}` },
+ chains: {},
+ storageDir: dir,
+ presenter: scripted.presenter,
+ logLevel: "off",
+ });
+ });
+
+ afterAll(async () => {
+ host.dispose();
+ await rm(dir, { recursive: true, force: true });
+ });
+
+ it("As a product, I write and read localStorage and the value round-trips through the Rust core", async () => {
+ // Given
+ const product = host.createProduct({ productId: "host-cli-test.dot" });
+ const client = createClient(createTransport(product.provider));
+
+ // When
+ const written = await client.localStorage.write({
+ key: "cache",
+ value: "0xdeadbeef",
+ });
+ expect(written.isOk()).toBe(true);
+
+ const read = await client.localStorage.read({ key: "cache" });
+
+ // Then
+ expect(read.isOk()).toBe(true);
+ expect(JSON.stringify(read.isOk() ? read.value : null)).toContain(
+ "deadbeef",
+ );
+
+ // The backing file exists, is owner-only, and carries the core's own
+ // product-storage namespace (product id, NO account component). That
+ // missing account component is why the host clears it on logout.
+ const mode = (await stat(host.storagePaths.product)).mode & 0o777;
+ expect(mode.toString(8)).toBe("600");
+ const persisted = JSON.parse(
+ await readFile(host.storagePaths.product, "utf8"),
+ ) as Record;
+ const keys = Object.keys(persisted);
+ expect(keys.length).toBeGreaterThan(0);
+ for (const key of keys) {
+ expect(key).toMatch(/^truapi:product-storage:v1:.*host-cli-test\.dot/);
+ }
+
+ product.dispose();
+ });
+
+ it("As a host embedder, I request a login offline and receive a Pairing deeplink to render headless", async () => {
+ // Given
+ const product = host.createProduct({ productId: "host-cli-test.dot" });
+ const client = createClient(createTransport(product.provider));
+
+ // When
+ // Do NOT await: requestLogin resolves only at a terminal state. The
+ // pairing presentation is the host's job and arrives via AuthState.
+ const login = client.account
+ .requestLogin({ reason: "host-cli integration test" })
+ .then(
+ () => undefined,
+ () => undefined,
+ );
+
+ // Then
+ const pairing = await host.waitForAuthState(
+ (state) => state.tag === "Pairing",
+ 15_000,
+ );
+ if (pairing.tag !== "Pairing") {
+ throw new Error("unreachable");
+ }
+ expect(pairing.value.deeplink).toMatch(/^polkadotapp:\/\//);
+ expect(host.authState()?.tag).toBe("Pairing");
+
+ // The scripted presenter saw the same state. That is the QR hook.
+ expect(scripted.states.some((state) => state.tag === "Pairing")).toBe(true);
+
+ host.cancelPairing();
+ await host.waitForAuthState(
+ (state) => state.tag === "Disconnected" || state.tag === "LoginFailed",
+ 15_000,
+ );
+ await login;
+ product.dispose();
+ });
+
+ it("As a CLI user, I log out and the host clears product storage", async () => {
+ // Given
+ const product = host.createProduct({ productId: "host-cli-test.dot" });
+ const client = createClient(createTransport(product.provider));
+ await client.localStorage.write({ key: "leftover", value: "0x01" });
+ expect(existsSync(host.storagePaths.product)).toBe(true);
+
+ // When
+ await host.disconnectSession();
+
+ // Then
+ expect(existsSync(host.storagePaths.product)).toBe(false);
+ // Core storage (the host's own slots) survives. Only the product side is
+ // identity-tainted.
+ product.dispose();
+ });
+});
diff --git a/packages/host-cli/tests/kv.test.ts b/packages/host-cli/tests/kv.test.ts
new file mode 100644
index 00000000..aedac762
--- /dev/null
+++ b/packages/host-cli/tests/kv.test.ts
@@ -0,0 +1,186 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// The core routes a product's localStorage into this store, so its contents
+// are per-user secrets by default. These tests pin the permissions, because
+// the failure is silent: a 0644 file works perfectly and is simply readable
+// by every other local user.
+
+import { describe, it, expect, afterEach } from "vitest";
+import {
+ mkdtemp,
+ stat,
+ chmod,
+ rm,
+ readFile,
+ writeFile,
+} from "node:fs/promises";
+import { existsSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import { FileKeyValueStore } from "../src/kv.js";
+
+const dirs: string[] = [];
+
+async function storeIn(...segments: string[]) {
+ const dir = await mkdtemp(join(tmpdir(), "host-cli-kv-"));
+ dirs.push(dir);
+ const path = join(dir, ...segments);
+ return { path, store: new FileKeyValueStore(path) };
+}
+
+const modeOf = async (path: string): Promise =>
+ ((await stat(path)).mode & 0o777).toString(8);
+
+afterEach(async () => {
+ await Promise.all(
+ dirs.splice(0).map((d) => rm(d, { recursive: true, force: true })),
+ );
+});
+
+describe("FileKeyValueStore", () => {
+ it("As a CLI user, my host storage file is owner-only", async () => {
+ // Given
+ const { path, store } = await storeIn("kv.json");
+
+ // When
+ await store.set("dotli:myapp.dot:token", "secret");
+
+ // Then
+ expect(await modeOf(path)).toBe("600");
+ });
+
+ it("As a CLI user, a missing parent directory is created owner-only", async () => {
+ // Given
+ const { path, store } = await storeIn("nested", "kv.json");
+
+ // When
+ await store.set("k", "v");
+
+ // Then
+ expect(await modeOf(join(path, ".."))).toBe("700");
+ });
+
+ it("As a CLI user, I upgrade from a build that left the store world-readable and the next write tightens it", async () => {
+ // Given
+ const { path, store } = await storeIn("kv.json");
+ await store.set("k", "v");
+ await chmod(path, 0o644);
+
+ // When
+ // Any later write must repair it: `mode` on writeFile only applies at
+ // creation, so without an explicit chmod the file would stay 0644 forever.
+ await store.set("k2", "v2");
+
+ // Then
+ expect(await modeOf(path)).toBe("600");
+ });
+
+ it("As a product, I write keys and can read, list by prefix, and delete them with the file kept in sync", async () => {
+ // Given
+ const { path, store } = await storeIn("kv.json");
+
+ // When
+ await store.set("dotli:a:one", "1");
+ await store.set("dotli:b:two", "2");
+
+ // Then
+ expect(await store.get("dotli:a:one")).toBe("1");
+ expect(await store.keys("dotli:a:")).toEqual(["dotli:a:one"]);
+ await store.delete("dotli:a:one");
+ expect(await store.get("dotli:a:one")).toBeNull();
+ // Persisted, not just cached.
+ expect(JSON.parse(await readFile(path, "utf8"))).toEqual({
+ "dotli:b:two": "2",
+ });
+ });
+
+ // Regression: serializing only the write chain left the LOAD unguarded, so
+ // two concurrent set() calls each read the file and the second cache
+ // assignment discarded the first caller's key. Both writes resolved, yet
+ // one key vanished.
+ it("As a product, no key is lost when two writes race", async () => {
+ // Given
+ const { path, store } = await storeIn("kv.json");
+ // Seed the FILE, not the instance: the race only exists while the lazy
+ // cache is still unpopulated, so a prior set() through the same store
+ // would hide it.
+ await writeFile(path, JSON.stringify({ seed: "1" }), "utf8");
+
+ // When
+ await Promise.all([store.set("a", "A"), store.set("b", "B")]);
+
+ // Then
+ expect(await store.get("a")).toBe("A");
+ expect(await store.get("b")).toBe("B");
+ expect(JSON.parse(await readFile(path, "utf8"))).toEqual({
+ seed: "1",
+ a: "A",
+ b: "B",
+ });
+ });
+
+ it("As a product, no key is lost when a write races a delete", async () => {
+ // Given
+ const { path, store } = await storeIn("kv.json");
+ await writeFile(path, JSON.stringify({ keep: "1", gone: "2" }), "utf8");
+
+ // When
+ await Promise.all([store.set("added", "3"), store.delete("gone")]);
+
+ // Then
+ expect(await store.get("added")).toBe("3");
+ expect(await store.get("gone")).toBeNull();
+ expect(await store.get("keep")).toBe("1");
+ });
+
+ it("As a host embedder, the store exposes its file path so I can clear it on logout", async () => {
+ // Given
+ const { path, store } = await storeIn("kv.json");
+
+ // Then
+ // The core keys product entries by product id, not by account, so a
+ // consumer that switches identities must be able to find and delete this
+ // file.
+ expect(store.filePath).toBe(path);
+ });
+
+ it("As a host embedder, I clear the store and both the file and the cached values are gone", async () => {
+ // Given
+ const { path, store } = await storeIn("kv.json");
+ await store.set("a", "A");
+
+ // When
+ await store.clear();
+
+ // Then
+ expect(existsSync(path)).toBe(false);
+ expect(await store.get("a")).toBeNull();
+ });
+
+ it("As a host embedder, no pre-clear data survives a clear() racing a write", async () => {
+ // Given
+ const { path, store } = await storeIn("kv.json");
+ await store.set("a", "A");
+
+ // When
+ // Which side wins is inherently racy. The invariant is that pre-clear
+ // data is gone from memory AND disk, and memory agrees with disk.
+ await Promise.all([store.set("b", "B"), store.clear()]);
+
+ // Then
+ expect(await store.get("a")).toBeNull();
+ const inMemoryB = await store.get("b");
+ if (existsSync(path)) {
+ const persisted = JSON.parse(await readFile(path, "utf8")) as Record<
+ string,
+ string
+ >;
+ expect(persisted.a).toBeUndefined();
+ expect(persisted.b ?? null).toBe(inMemoryB);
+ } else {
+ expect(inMemoryB).toBeNull();
+ }
+ });
+});
diff --git a/packages/host-cli/tests/operation-order.test.ts b/packages/host-cli/tests/operation-order.test.ts
new file mode 100644
index 00000000..af2c0873
--- /dev/null
+++ b/packages/host-cli/tests/operation-order.test.ts
@@ -0,0 +1,161 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// The shim's contract, measured against the core (tier 8): an operation's
+// events must never reach the consumer before the start-response that names
+// the operation. papi drops early events silently and the read never
+// settles.
+
+import { describe, it, expect } from "vitest";
+import {
+ serializeOperationStarts,
+ type JsonRpcProvider,
+} from "../src/operation-order.js";
+
+const startResponse = (id: string, operationId: string) => ({
+ jsonrpc: "2.0",
+ id,
+ result: { result: "started", operationId },
+});
+
+const operationEvent = (event: string, operationId: string) => ({
+ jsonrpc: "2.0",
+ method: "chainHead_v1_followEvent",
+ params: { subscription: "f1", result: { event, operationId } },
+});
+
+const followEvent = (event: string) => ({
+ jsonrpc: "2.0",
+ method: "chainHead_v1_followEvent",
+ params: { subscription: "f1", result: { event } },
+});
+
+/** A provider we can push messages through, recording what the consumer saw. */
+function harness() {
+ let push: (message: unknown) => void = () => {};
+ const provider: JsonRpcProvider = (onMessage) => {
+ push = onMessage;
+ return { send: () => {}, disconnect: () => {} };
+ };
+ const seen: unknown[] = [];
+ serializeOperationStarts(provider)((message) => seen.push(message));
+ return { push: (message: unknown) => push(message), seen };
+}
+
+const eventNames = (seen: unknown[]): string[] =>
+ seen.map((message) => {
+ const m = message as {
+ id?: string;
+ params?: { result?: { event?: string } };
+ };
+ return m.params?.result?.event ?? `response:${m.id ?? "?"}`;
+ });
+
+describe("serializeOperationStarts", () => {
+ it("As a papi consumer, already-ordered traffic reaches me untouched", () => {
+ // Given
+ const { push, seen } = harness();
+
+ // When
+ push(followEvent("initialized"));
+ push(startResponse("r1", "1"));
+ push(operationEvent("operationStorageItems", "1"));
+ push(operationEvent("operationStorageDone", "1"));
+
+ // Then
+ expect(eventNames(seen)).toEqual([
+ "initialized",
+ "response:r1",
+ "operationStorageItems",
+ "operationStorageDone",
+ ]);
+ });
+
+ it("As a papi consumer, I never receive an operation event before the start-response that names it", () => {
+ // Given
+ const { push, seen } = harness();
+ push(followEvent("initialized"));
+ // The measured inversion: items and done arrive BEFORE the response that
+ // names operationId 0.
+ push(operationEvent("operationStorageItems", "0"));
+ push(operationEvent("operationStorageDone", "0"));
+ expect(eventNames(seen)).toEqual(["initialized"]);
+
+ // When
+ push(startResponse("r1", "0"));
+
+ // Then
+ expect(eventNames(seen)).toEqual([
+ "initialized",
+ "response:r1",
+ "operationStorageItems",
+ "operationStorageDone",
+ ]);
+ });
+
+ it("As a papi consumer, interleaved operations are gated independently", () => {
+ // Given
+ const { push, seen } = harness();
+
+ // When
+ push(startResponse("r1", "1"));
+ push(operationEvent("operationStorageItems", "2")); // held: not announced
+ push(operationEvent("operationStorageItems", "1")); // flows: announced
+ push(startResponse("r2", "2")); // releases the held event
+
+ // Then
+ expect(eventNames(seen)).toEqual([
+ "response:r1",
+ "operationStorageItems",
+ "response:r2",
+ "operationStorageItems",
+ ]);
+ });
+
+ it("As a papi consumer, a start-response from a stopped follow never releases the refollow's events", () => {
+ // Given
+ const { push, seen } = harness();
+ push(followEvent("stop"));
+ // In flight from the dead follow. papi registered no subscriber for it.
+ push(startResponse("r1", "1"));
+ // The refollow reuses small operation numbers. Its "1" must NOT be
+ // un-gated by the dead follow's start-response.
+ push(operationEvent("operationStorageItems", "1"));
+ expect(eventNames(seen)).toEqual(["stop", "response:r1"]);
+
+ // When
+ // The refollow comes alive. Its own start-response releases the event.
+ push(followEvent("initialized"));
+ push(startResponse("r2", "1"));
+
+ // Then
+ expect(eventNames(seen)).toEqual([
+ "stop",
+ "response:r1",
+ "initialized",
+ "response:r2",
+ "operationStorageItems",
+ ]);
+ });
+
+ it("As a papi consumer, a reused operation id is gated again after its terminal event", () => {
+ // Given
+ const { push, seen } = harness();
+ push(startResponse("r1", "1"));
+ push(operationEvent("operationStorageDone", "1"));
+ // A NEW operation reusing the id must wait for its own start-response.
+ push(operationEvent("operationStorageItems", "1"));
+ expect(eventNames(seen)).toEqual(["response:r1", "operationStorageDone"]);
+
+ // When
+ push(startResponse("r2", "1"));
+
+ // Then
+ expect(eventNames(seen)).toEqual([
+ "response:r1",
+ "operationStorageDone",
+ "response:r2",
+ "operationStorageItems",
+ ]);
+ });
+});
diff --git a/packages/host-cli/tsconfig.build.json b/packages/host-cli/tsconfig.build.json
new file mode 100644
index 00000000..a8d421a4
--- /dev/null
+++ b/packages/host-cli/tsconfig.build.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src"]
+}
diff --git a/packages/host-cli/tsconfig.json b/packages/host-cli/tsconfig.json
new file mode 100644
index 00000000..b8c70cff
--- /dev/null
+++ b/packages/host-cli/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "@dotli/typescript-config/base.json",
+ "compilerOptions": {
+ // This package is PUBLISHED and runs under plain node, so it deviates from
+ // the app packages: nodenext resolution (relative imports carry a `.js`
+ // extension), no DOM lib, and node types instead of vite's.
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "allowImportingTsExtensions": false,
+ "lib": ["ES2022"],
+ "types": ["node"]
+ },
+ "include": ["src", "tests", "examples"]
+}
diff --git a/packages/host-cli/vitest.config.ts b/packages/host-cli/vitest.config.ts
new file mode 100644
index 00000000..a38be654
--- /dev/null
+++ b/packages/host-cli/vitest.config.ts
@@ -0,0 +1,11 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ // The integration test boots the real 2 MiB wasm core.
+ testTimeout: 30_000,
+ },
+});
diff --git a/scripts/set-version.ts b/scripts/set-version.ts
index 700f9697..2516985b 100644
--- a/scripts/set-version.ts
+++ b/scripts/set-version.ts
@@ -104,10 +104,19 @@ function readVersionedPackages(): PkgVersion[] {
const result: PkgVersion[] = [];
for (const file of findPackageJsons()) {
const text = readFileSync(file, "utf8");
- const pkg = JSON.parse(text) as { version?: unknown };
- if (typeof pkg.version === "string") {
- result.push({ file, version: pkg.version, text });
+ const pkg = JSON.parse(text) as { version?: unknown; private?: unknown };
+ if (typeof pkg.version !== "string") {
+ continue;
+ }
+ // PUBLISHED packages (not `private: true`) version independently: their
+ // semver tracks their own API and releases via their own tags (e.g.
+ // `host-cli-v0.1.0`), not the app's release cadence. Syncing them here
+ // would republish-or-break them on every app release. (The root
+ // package.json is private and versionless, so it is skipped above.)
+ if (pkg.private !== true) {
+ continue;
}
+ result.push({ file, version: pkg.version, text });
}
return result;
}