@kontextmind/kxm 0.7.166 → 0.7.168

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +18 -0
  3. package/docs/README.md +1 -0
  4. package/docs/architecture/hosted-direction.md +9 -4
  5. package/docs/architecture/inventory.md +1 -0
  6. package/docs/contributing/ci-and-release.md +6 -1
  7. package/docs/contributing/decisions.md +153 -0
  8. package/docs/contributing/learnings.md +54 -53
  9. package/docs/contributing/operating-rules.md +19 -6
  10. package/docs/operations/runtime-sync.md +2 -1
  11. package/docs/operations/troubleshooting.md +4 -0
  12. package/docs/operations/upgrade.md +18 -3
  13. package/docs/operations.md +70 -7
  14. package/docs/reference/cli-reference.md +21 -6
  15. package/docs/reference/config-reference.md +4 -1
  16. package/docs/start/install.md +1 -1
  17. package/package.json +1 -1
  18. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  19. package/plugins/kxm/dist/claude-hook.js +170 -36
  20. package/plugins/kxm/dist/cli.js +332 -129
  21. package/plugins/kxm/dist/extension.js +184 -53
  22. package/plugins/kxm/dist/mcp-server.js +97 -40
  23. package/plugins/kxm/dist/runtime-supervisor.js +113 -42
  24. package/plugins/kxm/dist/runtime.js +113 -42
  25. package/plugins/kxm/dist/server.js +2 -0
  26. package/plugins/kxm/package.json +1 -1
  27. package/plugins/kxm/skills/kxm-harness-auth/SKILL.md +4 -2
  28. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +9 -4
  29. package/plugins/kxm/src/cli/hub.ts +42 -7
  30. package/plugins/kxm/src/cli/system.ts +3 -1
  31. package/plugins/kxm/src/cli/types.ts +5 -3
  32. package/plugins/kxm/src/cli.ts +2 -1
  33. package/plugins/kxm/src/hub-binding.ts +301 -55
  34. package/plugins/kxm/src/hub-identity.ts +17 -9
  35. package/plugins/kxm/src/kxm-update.ts +29 -6
  36. package/plugins/kxm/src/mcp-server.ts +1 -1
  37. package/plugins/kxm/src/runtime-supervisor.ts +22 -4
  38. package/scripts/kxm-publish-npm.mjs +47 -1
@@ -88,16 +88,32 @@ kxm hub bind --cloud \
88
88
  http://127.0.0.1:17331
89
89
  ```
90
90
 
91
- Expected output:
91
+ Expected output when `KXMD_HUB_TOKEN` is set:
92
92
 
93
93
  ```text
94
94
  bound hub http://127.0.0.1:17331 · remote · health=on · token is not stored
95
95
  hub health=true ready=true · remote hub · mode=cloud project=prj_example (.kxm/config.yaml hub.cloud.project) key=env:KXMD_HUB_TOKEN
96
96
  ```
97
97
 
98
+ With only `--token-command`, and `KXMD_HUB_TOKEN` unset, `kxm hub view` prints
99
+ `key=token-command`. It names the source that would actually supply the token:
100
+ `env:<NAME>` only when that variable is non-empty, otherwise `op:<ref>`,
101
+ `token-command`, `hub-env`, or `missing`. An unset variable is not reported as
102
+ the source.
103
+
98
104
  `--token-env` and `--token-command` can be combined. A non-empty variable wins.
99
105
  Otherwise the command runs. The command is a program and arguments, not a shell
100
- pipeline (`|`, `&`, `;`, and redirection are refused).
106
+ pipeline (`|`, `&`, `;`, and redirection are refused). `--token-command` is
107
+ stored only in the machine-wide binding (`hub-binding.cloud.json` under the
108
+ user state root). It is not written to `.kxm/config.yaml`. Config holds
109
+ `key.op` and `key.env` references only.
110
+
111
+ `https://hub.kxmd.dev` is behind an Authentik forward-auth proxy. Clients
112
+ cannot use it: `GET /health` returns the login page (HTTP 200 HTML, or a 302).
113
+ `kxm hub view` treats that as unhealthy (`health=false`, and the JSON `health`
114
+ field is `{ "error": "auth_proxy" }`, not the HTML). `kxm hub bind` refuses
115
+ with `hub_not_kxm` and writes nothing unless you pass `--force`. The supported
116
+ client path is the SSH forward above.
101
117
 
102
118
  <details><summary>PowerShell</summary>
103
119
 
@@ -114,8 +130,8 @@ kxm hub view
114
130
  | | Local bind | `--cloud` bind |
115
131
  |---|---|---|
116
132
  | Loopback URL | Scope `loopback`. No credential required | Scope `remote`, including an SSH forward |
117
- | Token stored in `hub-binding.json` | No | No. The file names `tokenEnv` and/or `tokenCommand` |
118
- | Credential later commands send | `KXM_AUTH_TOKEN`, then the active mode's key reference in `.kxm/config.yaml`, then this machine's hub-env | The active mode's key reference, then the binding's named source. Hub-env is not a fallback |
133
+ | Token stored in the binding | No | No. `hub-binding.cloud.json` names `tokenEnv` and/or `tokenCommand`. `hub-binding.json` stays a three-key local record, or is omitted, so a 0.7.159 reader does not throw |
134
+ | Credential later commands send | Agent commands use `KXM_AUTH_TOKEN`, then `hub.local` `key.env` / `key.op`, then this project's saved project token. They never use the hub-env admin token (`project_token_missing`). `kxm hub view` and `kxm tenant status` may use the admin token | The active mode's key reference, then the binding's named source. Hub-env is not a fallback |
119
135
  | Missing source | Agent commands report `project_token_missing` and name the project id that was sent | `cloud_token_missing` or `cloud_token_command_failed`. The local admin token is not sent |
120
136
  | `kxm hub start` from an extension | May start a local hub | Does not start a local hub when the forward is down (`cloud_hub_unreachable`) |
121
137
 
@@ -124,6 +140,47 @@ kxm hub view
124
140
  all use that source. `kxm agent worker` puts `KXM_AUTH_TOKEN` in the child
125
141
  process environment only. It does not write the token to disk.
126
142
 
143
+ A local bind does not give agents the admin token from hub-env. Give them a
144
+ project token in one of these ways:
145
+
146
+ - On the client, export `KXM_AUTH_TOKEN` to that project's token, or set
147
+ `hub.local.key.env` / `hub.local.key.op` in `.kxm/config.yaml`.
148
+ - On the hub, map the project id in `hub.projects` (an `op://` reference or an
149
+ environment variable name) or in `KXM_PROJECT_TOKENS`, keyed by the same id
150
+ the client sends.
151
+
152
+ ### Where to store tokens
153
+
154
+ Keep hub admin and project tokens in 1Password and point KXM at `op://` references
155
+ (`--key-op`, `key.op`, or `--token-command "op read op://..."`). Do not paste the
156
+ token into `.kxm/config.yaml`, `hub-binding.json`, or `hub-binding.cloud.json`.
157
+
158
+ A hub host that has no `op` binary should take the token from the environment
159
+ or from a systemd credentials file. The unit in
160
+ [Supervise the hub and Runtime](operations/deploy.md#the-hub) loads
161
+ `EnvironmentFile=/etc/kxm/hub.env` (mode 0600) for `KXM_AUTH_TOKEN` and
162
+ `KXM_PROJECT_TOKENS`.
163
+
164
+ ### Restart after an upgrade, and before a cloud bind
165
+
166
+ Restart the Runtime supervisor, the hub, and workers after upgrading and before
167
+ `kxm hub bind --cloud`:
168
+
169
+ ```bash
170
+ kxm runtime stop
171
+ kxm hub stop
172
+ kxm runtime start
173
+ kxm hub start
174
+ ```
175
+
176
+ A 0.7.159 supervisor treats any extra key in `hub-binding.json` as
177
+ `malformed hub binding` and stops syncing. This release keeps cloud fields in
178
+ `hub-binding.cloud.json` and leaves `hub-binding.json` as a three-key local
179
+ record, or omits it. An older reader then keeps the local hub instead of
180
+ throwing. A binding newer than this build is ignored with a warning, and sync
181
+ continues as `no_hub` rather than stalling. Processes that were already running
182
+ keep the binding they loaded at start, so restart them after the upgrade.
183
+
127
184
  The hub project id is the same value everywhere: `--project` or `KXM_PROJECT`,
128
185
  then the active mode's `project` in `.kxm/config.yaml`, then the `id` in
129
186
  `.kxm/project.yaml`, then `package.json` `name`, then the directory name.
@@ -147,7 +204,8 @@ hub:
147
204
  op: op://Private/kxm/local-project-token
148
205
  env: KXM_LOCAL_PROJECT_TOKEN
149
206
  cloud:
150
- url: https://hub.kxmd.dev
207
+ # Clients use an SSH forward. https://hub.kxmd.dev is an Authentik login page.
208
+ url: http://127.0.0.1:17331
151
209
  project: prj_example
152
210
  key:
153
211
  op: op://Private/kxmd/project-token
@@ -167,7 +225,8 @@ with `hub_config_invalid`.
167
225
  `kxm hub bind` and `kxm hub bind --cloud` write the mode, URL, project id, and
168
226
  key reference. `--key-op` is the `op://` reference for either mode.
169
227
  `--token-env` names the variable for either mode. `--token-command` stays
170
- cloud-only. `kxm hub view` prints the mode, URL, project id, and key source.
228
+ cloud-only, and only in `hub-binding.cloud.json`, not in this file.
229
+ `kxm hub view` prints the mode, URL, project id, and key source.
171
230
  It does not print the token. `kxm hub unbind` removes the binding and
172
231
  `hub.mode`, and leaves the `local` and `cloud` blocks in place.
173
232
 
@@ -185,7 +244,8 @@ send the `.kxm/project.yaml` id and the hub has no token for it.
185
244
  kxm hub unbind
186
245
  ```
187
246
 
188
- Unbind deletes `hub-binding.json` and clears `hub.mode` in `.kxm/config.yaml`.
247
+ Unbind deletes `hub-binding.json` and `hub-binding.cloud.json`, and clears
248
+ `hub.mode` in `.kxm/config.yaml`.
189
249
  The `local` and `cloud` blocks stay, so the next bind can switch mode again.
190
250
  It does not delete hub-env, and it does not close the SSH forward. Stop the
191
251
  forward by ending the `ssh -N` session.
@@ -200,6 +260,9 @@ forward by ending the `ssh -N` session.
200
260
  | `cloud_token_command_failed` | `op` exited non-zero, timed out, or printed more than one line | Run the same command in a terminal and sign in to 1Password. Do not paste the token into the binding |
201
261
  | Clients through one SSH host get locked out together | The hub rate-limits by agent id, otherwise by source address. Every forward from one SSH session shares `127.0.0.1` on the hub. `sshd` `MaxStartups` and `MaxSessions` can refuse new forwards first | Give each long-lived agent its own name. Raise the SSH limits on that host if many people share one jump host. Do not point two tenants at one hub |
202
262
  | `kxm hub view` says `loopback` for the forward | `--cloud` was omitted | Bind again with `--cloud`. `KXM_SERVER_URL` overrides the binding URL and is labeled on its own |
263
+ | `hub_not_kxm`, or `kxm hub view` prints `auth proxy` with `health=false` | The URL is an Authentik (or other) login page, or a 30x redirect, not a kxm hub. `https://hub.kxmd.dev` is that case | Bind `http://127.0.0.1:17331` through the SSH forward. `--force` writes the binding anyway and is not a working client path |
264
+ | `key=env:KXMD_HUB_TOKEN` while that variable is unset | An older `kxm hub view` named the configured variable even when it was empty | Upgrade. The line names `token-command`, `op:<ref>`, `hub-env`, or `missing` for the source that actually supplies the token |
265
+ | `malformed hub binding` and sync stops after a cloud bind | A supervisor older than this release read cloud fields in `hub-binding.json` | Upgrade, then restart the supervisor, hub, and workers before binding `--cloud` again |
203
266
 
204
267
  ## Next steps
205
268
 
@@ -477,12 +477,12 @@ Starts, inspects, and stops the local KXM hub, and binds this machine to a hub.
477
477
  kxm hub view
478
478
  ```
479
479
 
480
- Calls `GET /health` and `GET /ready` on the target hub and reports whether the URL is loopback or remote, plus the mode, project id, and key source in effect. The key source is an environment variable name or an `op://` reference. The token is not printed.
480
+ Calls `GET /health` and `GET /ready` on the target hub and reports whether the URL is loopback or remote, plus the mode, project id, and key source in effect. A probe counts only when the response is JSON (`application/json`, or `text/plain` with a JSON object) of the hub shape: `/health` is `{ "ok": true, "agents": <number> }` and `/ready` is `{ "ok": true, "storage": "sqlite"|"memory" }`. An HTTP 200 HTML login page, or a 30x redirect, is not healthy. The JSON `health` and `ready` fields then hold `{ "error": "auth_proxy" }` and never the HTML. The key source is the one that would supply the token: `env:<NAME>` only when that variable is non-empty, `op:<ref>`, `token-command`, `hub-env`, or `missing`. The token is not printed.
481
481
 
482
482
  No command-specific options.
483
483
 
484
484
  - Needs a hub to succeed. Reads only.
485
- - JSON keys: `target` (`url`, `scope`, and `source: "env"` when `KXM_SERVER_URL` overrides a binding), `identity` (`mode`, `modeSource`, `url`, `urlSource`, `project`, `projectSource`, `keySource`), `health`, `ready`. An unreachable hub reports `{"error":"hub_unreachable"}` for both probes.
485
+ - JSON keys: `target` (`url`, `scope`, and `source: "env"` when `KXM_SERVER_URL` overrides a binding), `identity` (`mode`, `modeSource`, `url`, `urlSource`, `project`, `projectSource`, `keySource`), `health`, `ready`. An unreachable hub reports `{"error":"hub_unreachable"}` for both probes. An auth proxy or redirect reports `{"error":"auth_proxy"}`.
486
486
  - Exit 0 when both probes succeed, 1 otherwise. A cloud mode with no resolvable key exits 2 with `cloud_token_missing`.
487
487
 
488
488
  ```bash
@@ -569,15 +569,16 @@ kxm hub stop --wait-ms 8000 --json
569
569
  ### `kxm hub bind`
570
570
 
571
571
  ```text
572
- kxm hub bind <url> [--cloud] [--token-env <name>] [--token-command <command>] [--key-op <op://vault/item/field>]
572
+ kxm hub bind <url> [--cloud] [--token-env <name>] [--token-command <command>] [--key-op <op://vault/item/field>] [--force]
573
573
  ```
574
574
 
575
- Binds this machine to a running hub by writing `hub-binding.json` under the user state root and the non-secret endpoint (mode, URL, project id, key reference) into `.kxm/config.yaml`, then probes the hub's health for up to 300 ms. Every hub client uses that endpoint when `KXM_SERVER_URL` is unset. The token is never written.
575
+ Binds this machine to a running hub by writing the binding under the user state root and the non-secret endpoint (mode, URL, project id, key reference) into `.kxm/config.yaml`, then probes the hub's health for up to 300 ms. A local bind writes `hub-binding.json` (`schema`, `url`, `boundAt` only). A `--cloud` bind writes `hub-binding.cloud.json` and leaves a three-key local file in place, or omits `hub-binding.json`, so a 0.7.159 reader does not throw `malformed hub binding` and stop sync. `--token-command` is stored only in that cloud file, not in `.kxm/config.yaml`. Every hub client uses that endpoint when `KXM_SERVER_URL` is unset. The token is never written.
576
576
 
577
577
  - Arguments: `<url>`, Hub base URL (http or https).
578
578
  - A URL with credentials, a query, a fragment, or a scheme other than http or https fails with `hub_url_invalid` (exit 2).
579
579
  - A remote (non-loopback) URL is refused with `hub_bind_unauthenticated` (exit 2, `nextAction: "export_kxm_auth_token"`) unless a credential for the current project resolves from `KXM_AUTH_TOKEN` or the persisted `hub-env.json`. A malformed record fails with `hub_credential_unreadable` (exit 2). A loopback URL without `--cloud` still binds with no credential.
580
580
  - `--cloud` marks the binding remote even when the URL is a loopback forward. It requires `--token-env`, `--token-command`, `--key-op`, or a combination. The token is not written. A non-empty variable wins; otherwise an `op://` reference is read with `op read`, or the command runs. `--token-command` without `--cloud` fails with `cloud_flag_required`. `--token-env` and `--key-op` are also valid on a local bind and are stored only as references in config. A missing cloud source later fails with `cloud_token_missing` and does not fall back to hub-env. See [Cross-box peer attach](../operations.md#cross-box-peer-attach).
581
+ - The probe accepts a kxm `/health` body. A timeout (`health=unknown`) or a connection failure (`health=off`) still writes the binding. An HTML login page or a 30x redirect is `hub_not_kxm` (exit 2, `kind` `auth_proxy` or `redirect`) and writes nothing. The error names an auth proxy or a redirect. `--force` writes anyway. `https://hub.kxmd.dev` is an Authentik login page; clients use the SSH forward at `http://127.0.0.1:17331`.
581
582
  - Mutates the binding file and `.kxm/config.yaml`. `--dry-run` validates and prints the plan without writing.
582
583
  - JSON keys: `url`, `scope`, `file`, `configFile`, `project`, `health` (`on`, `off`, or `unknown`), `probeMs`. A cloud bind also returns `cloud`, and `tokenEnv` or `tokenCommand` when set.
583
584
 
@@ -984,7 +985,20 @@ Checks for or applies a KXM operator package update, and runs the native updater
984
985
 
985
986
  - Arguments: `[harness]`, Harness id (default: every detected harness).
986
987
  - `--check` cannot be combined with any other update flag or a harness (exit 2, `scope_conflict`), and at most one of `--self`, `--extensions`, `--models` is allowed (exit 2).
987
- - From a source checkout, `--check` reports the running version without network access, and `--kxm` is refused (exit 2, `install_kind_source`) with an instruction to `git pull`. Only npm-global installs can apply `--kxm`; other install kinds exit 2 with `install_kind_<kind>`. A GitHub release must publish a sha256 digest for `kxm-<version>.tgz` or the install fails closed (`release_digest_missing`, `release_digest_mismatch`).
988
+ - From a source checkout, `--check` reports the running version without network access, and `--kxm` is refused (exit 2, `install_kind_source`) with an instruction to `git pull`. Only npm-global installs can apply `--kxm`; other install kinds exit 2 with `install_kind_<kind>`. A GitHub release must publish a sha256 digest for `kxm-<version>.tgz` or the install fails closed (`release_digest_missing`, `release_digest_mismatch`). When `gh` is missing, the github source downloads that same asset with `curl --fail --silent --show-error --location` and still verifies the digest before `npm install -g`.
989
+
990
+ On a host with neither `gh` nor a working `kxm`, download and check it yourself:
991
+
992
+ ```bash
993
+ version=0.7.166
994
+ curl --fail --silent --show-error --location \
995
+ --output "kxm-${version}.tgz" \
996
+ "https://github.com/kontextmind/kxm/releases/download/v${version}/kxm-${version}.tgz"
997
+ # Compare with the sha256 digest published on the GitHub release asset.
998
+ sha256sum "kxm-${version}.tgz"
999
+ npm install --global --omit=peer "./kxm-${version}.tgz"
1000
+ ```
1001
+
988
1002
  - Settings come only from `update.yaml` under the user state root (`auto: true` enables auto-apply); a project `.kxm/update.yaml` is ignored with a warning.
989
1003
  - Without `--check` or a lone `--kxm`, KXM probes harnesses (as `harness list` does) and runs each updater for the selected scope. An unknown harness id exits 2 (`unknown_harness` step); a failed step exits 1.
990
1004
  - Honors `--dry-run`: steps are planned, not run, and the cached update notice is not refreshed.
@@ -1612,7 +1626,7 @@ Create a KXM run without executing steps. The run pins the project's `homeRuntim
1612
1626
  - Refuses `--workspace` (exit 2, `workspace_option_unsupported`).
1613
1627
  - Needs a KXM project. Starts and uses the Runtime; no hub needed. Honors `--dry-run`, which validates the project and prints the plan without starting the supervisor.
1614
1628
  - JSON keys: `idempotent`, `run` (`runId`, `homeRuntimeId`, `status`, `configRevision`), `supervisor` (`runtimeId`, `port`, `started`), and `execution` (`status: not_started`, `mode: live`, `defaultHarness`, `authentication: not_checked`, `prerequisites`, `nextSteps`). Dry run: `projectRoot`, `workflowId`, `configRevision`, `defaultHarness`, `prerequisites`. The obsolete `phase: pre-3a` field is no longer returned.
1615
- - Errors: `workflow_required` (exit 2), `project_required`, `run_workflow_unknown`, `brief_and_prompt`, `brief_unreadable`, `lane_missing`, `lane_run_open`, `run_failed` with `issues` (any invalid file in the project fails the load, for example `gate_outcome_impossible`), `run_io_failed` (exit 1).
1629
+ - Errors: `workflow_required` (exit 2), `project_required`, `run_workflow_unknown`, `brief_and_prompt`, `brief_unreadable`, `lane_missing`, `lane_run_open`, `run_failed` with `issues` (any invalid file in the project fails the load, for example `gate_outcome_impossible`), `run_io_failed` (exit 1). `project_home_conflict` means this checkout is not the registered home for that project id: a different repository already owns it, the home runtime is immutable, or the primary checkout is already the home row. Use the original checkout, or a worktree of that repository. Do not copy the project id into an unrelated clone, and do not delete the database. `runtime_schema_newer` means the store was written by a newer release than this build. Upgrade KXM. Do not delete the database.
1616
1630
  - The `default` workflow that `kxm init` writes does not set `limits.maxAgentTimeMs`, so it can be driven. A workflow that sets that limit is created, but `kxm runs drive` refuses it with `run_handoff_required` (`limit_unsupported`); projects from older `kxm init` templates carry it on `default`.
1617
1631
 
1618
1632
  ```bash
@@ -1826,6 +1840,7 @@ No command-specific options.
1826
1840
  - Reads only. Never starts the supervisor.
1827
1841
  - JSON keys: `running`, `runtimeId`, `pid`, `port`, `state`, `heartbeatAt`, `startedAt`, `sync`.
1828
1842
  - Exit 0 when running, 1 when not (the JSON still says `ok: true`).
1843
+ - When the supervisor process is up but `GET /v1/sync/status` fails, the text line is `sync: the supervisor is running but did not answer /v1/sync/status`. The usual causes are a hung supervisor, a token the status command does not hold, or a supervisor started before an upgrade. `kxm runtime stop` and `kxm runtime start` replace it. A binding this build cannot read is a warning and `no_hub`, not a stalled sync; restart after upgrading and before a cloud bind. See [Check sync status](../operations/runtime-sync.md#check-sync-status).
1829
1844
 
1830
1845
  ```bash
1831
1846
  kxm runtime status
@@ -1311,6 +1311,8 @@ no recorded cost is never ready.
1311
1311
  ```yaml
1312
1312
  # .kxm/config.yaml (project scope) or ~/.config/kxm/config.yaml (user scope).
1313
1313
  # The project file wins. Key fields are references, never tokens.
1314
+ # --token-command is not a config field. It lives only in the machine-wide
1315
+ # hub-binding.cloud.json written by `kxm hub bind --cloud`.
1314
1316
  hub:
1315
1317
  autoStart: background # background | off
1316
1318
  mode: local # local | cloud
@@ -1321,7 +1323,8 @@ hub:
1321
1323
  op: op://Private/kxm/local-project-token
1322
1324
  env: KXM_LOCAL_PROJECT_TOKEN
1323
1325
  cloud:
1324
- url: https://hub.kxmd.dev
1326
+ # Clients use an SSH forward. https://hub.kxmd.dev is an Authentik login page.
1327
+ url: http://127.0.0.1:17331
1325
1328
  project: prj_example
1326
1329
  key:
1327
1330
  op: op://Private/kxmd/project-token
@@ -8,7 +8,7 @@ KXM has three parts you can install: the `kxm` command-line tool, the Claude Cod
8
8
  - Git.
9
9
  - Claude Code, if you want the plugin.
10
10
  - Pi, if you want Pi agents. Install it with `npm install --global @earendil-works/pi-coding-agent` and sign in to a model provider as the [Pi documentation](https://pi.dev/docs/latest) describes.
11
- - The GitHub CLI (`gh`), only if you plan to update with `kxm update --kxm`, which downloads the release tarball from GitHub.
11
+ - The GitHub CLI (`gh`), only if you plan to update with `kxm update --kxm` from the GitHub source. When `gh` is missing, that command falls back to curl and still checks the release checksum. The manual curl commands are in [Upgrade KXM](../operations/upgrade.md#update-the-cli-without-gh).
12
12
 
13
13
  ## Choose what to install
14
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontextmind/kxm",
3
- "version": "0.7.166",
3
+ "version": "0.7.168",
4
4
  "description": "KXM local-first multi-agent orchestration and operator dashboard",
5
5
  "type": "module",
6
6
  "author": "KontextMind",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "kxm",
4
4
  "displayName": "KXM",
5
- "version": "0.7.166",
5
+ "version": "0.7.168",
6
6
  "description": "Headless multi-agent orchestration, durable workflows, and a live operator dashboard for Pi and Claude Code",
7
7
  "author": {
8
8
  "name": "KontextMind",
@@ -556,6 +556,9 @@ function resolveUserStateRoot(env) {
556
556
  function hubBindingFile(env = process.env) {
557
557
  return join3(resolveUserStateRoot(env), "hub-binding.json");
558
558
  }
559
+ function hubBindingCloudFile(env = process.env) {
560
+ return join3(resolveUserStateRoot(env), "hub-binding.cloud.json");
561
+ }
559
562
  function validateHubUrl(raw) {
560
563
  let parsed;
561
564
  try {
@@ -618,33 +621,35 @@ function isAbortError(error) {
618
621
  error && typeof error === "object" && ("name" in error && error.name === "AbortError" || "code" in error && error.code === "ABORT_ERR")
619
622
  );
620
623
  }
621
- function readHubBinding(env = process.env) {
622
- const file = hubBindingFile(env);
623
- if (!existsSync4(file)) return void 0;
624
+ function bindingFallbackWarning(file, reason) {
625
+ return `hub binding at ${file} ${reason}; falling back to the local hub. Restart the Runtime supervisor, the hub, and workers after upgrading and before a cloud bind.`;
626
+ }
627
+ function writeBindingJson(file, value) {
628
+ mkdirSync3(dirname(file), { recursive: true, mode: 448 });
629
+ const temporary = join3(dirname(file), `.${file.endsWith("cloud.json") ? "hub-binding-cloud" : "hub-binding"}-${process.pid}.tmp`);
630
+ try {
631
+ writeFileSync2(temporary, `${JSON.stringify(value, null, 2)}
632
+ `, { encoding: "utf8", mode: 384 });
633
+ renameSync(temporary, file);
634
+ } finally {
635
+ rmSync(temporary, { force: true });
636
+ }
637
+ }
638
+ function parseBindingObject(file) {
639
+ if (!existsSync4(file)) return new HubBindingError(`missing hub binding at ${file}`);
624
640
  let parsed;
625
641
  try {
626
642
  parsed = JSON.parse(readFileSync4(file, "utf8"));
627
643
  } catch {
628
- throw new HubBindingError(`malformed hub binding at ${file}`);
644
+ return new HubBindingError(`malformed hub binding at ${file}`);
629
645
  }
630
646
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
631
- throw new HubBindingError(`malformed hub binding at ${file}`);
632
- }
633
- const row = parsed;
634
- const keys = Object.keys(row);
635
- const allowed = /* @__PURE__ */ new Set(["schema", "url", "boundAt", "cloud", "tokenEnv", "tokenCommand"]);
636
- const cloudish = keys.some((key) => key === "cloud" || key === "tokenEnv" || key === "tokenCommand");
637
- if (keys.some((key) => !allowed.has(key)) || !cloudish && keys.length !== 3 || row.schema !== HUB_BINDING_SCHEMA || typeof row.url !== "string" || typeof row.boundAt !== "string" || !isIsoTimestamp(row.boundAt)) {
638
- throw new HubBindingError(`malformed hub binding at ${file}`);
639
- }
640
- let url;
641
- try {
642
- url = validateHubUrl(row.url);
643
- } catch {
644
- throw new HubBindingError(`malformed hub binding at ${file}`);
647
+ return new HubBindingError(`malformed hub binding at ${file}`);
645
648
  }
646
- if (!cloudish) return { schema: HUB_BINDING_SCHEMA, url, boundAt: row.boundAt };
647
- if (row.cloud !== true) throw new HubBindingError(`malformed hub binding at ${file}`);
649
+ return parsed;
650
+ }
651
+ function cloudRecordFromRow(file, row, url, boundAt) {
652
+ if (row.cloud !== true) return void 0;
648
653
  let tokenEnv;
649
654
  let tokenCommand;
650
655
  if (row.tokenEnv !== void 0) {
@@ -666,36 +671,163 @@ function readHubBinding(env = process.env) {
666
671
  return {
667
672
  schema: HUB_BINDING_SCHEMA,
668
673
  url,
669
- boundAt: row.boundAt,
674
+ boundAt,
670
675
  cloud: true,
671
676
  ...tokenEnv ? { tokenEnv } : {},
672
677
  ...tokenCommand ? { tokenCommand } : {}
673
678
  };
674
679
  }
680
+ function interpretBindingFile(file) {
681
+ if (!existsSync4(file)) return {};
682
+ const parsed = parseBindingObject(file);
683
+ if (parsed instanceof HubBindingError) throw parsed;
684
+ const keys = Object.keys(parsed);
685
+ const unknown = keys.filter((key) => !BINDING_KEYS.has(key));
686
+ if (parsed.schema !== HUB_BINDING_SCHEMA || unknown.length > 0) {
687
+ return { warning: bindingFallbackWarning(file, `is newer than this build understands (${String(parsed.schema)})`) };
688
+ }
689
+ if (typeof parsed.url !== "string" || typeof parsed.boundAt !== "string" || !isIsoTimestamp(parsed.boundAt)) {
690
+ throw new HubBindingError(`malformed hub binding at ${file}`);
691
+ }
692
+ let url;
693
+ try {
694
+ url = validateHubUrl(parsed.url);
695
+ } catch {
696
+ throw new HubBindingError(`malformed hub binding at ${file}`);
697
+ }
698
+ const cloudish = keys.some((key) => key === "cloud" || key === "tokenEnv" || key === "tokenCommand");
699
+ if (!cloudish) {
700
+ if (keys.length !== 3) throw new HubBindingError(`malformed hub binding at ${file}`);
701
+ return { record: { schema: HUB_BINDING_SCHEMA, url, boundAt: parsed.boundAt } };
702
+ }
703
+ const record = cloudRecordFromRow(file, parsed, url, parsed.boundAt);
704
+ if (!record) throw new HubBindingError(`malformed hub binding at ${file}`);
705
+ return { record, legacyCloud: true };
706
+ }
707
+ function loadHubBinding(env = process.env) {
708
+ const cloudFile = hubBindingCloudFile(env);
709
+ const cloud = interpretBindingFile(cloudFile);
710
+ if (cloud.warning && !cloud.record) {
711
+ const local = existsSync4(hubBindingFile(env)) ? interpretBindingFile(hubBindingFile(env)) : {};
712
+ if (local.record && !local.record.cloud) return { record: local.record, warning: cloud.warning };
713
+ return { warning: cloud.warning };
714
+ }
715
+ if (cloud.record?.cloud) return { record: cloud.record, ...cloud.warning ? { warning: cloud.warning } : {} };
716
+ const main2 = interpretBindingFile(hubBindingFile(env));
717
+ if (main2.legacyCloud && main2.record) {
718
+ writeBindingJson(cloudFile, main2.record);
719
+ rmSync(hubBindingFile(env), { force: true });
720
+ return {
721
+ record: main2.record,
722
+ warning: bindingFallbackWarning(
723
+ hubBindingFile(env),
724
+ "stored a cloud bind in the pre-0.7.160 file"
725
+ )
726
+ };
727
+ }
728
+ return { ...main2.record ? { record: main2.record } : {}, ...main2.warning ? { warning: main2.warning } : {} };
729
+ }
730
+ function readHubBinding(env = process.env) {
731
+ return loadHubBinding(env).record;
732
+ }
733
+ function contentTypeBase(header) {
734
+ return (header ?? "").split(";")[0].trim().toLowerCase();
735
+ }
736
+ function looksLikeHtml(contentType, body) {
737
+ if (contentType === "text/html" || contentType === "application/xhtml+xml") return true;
738
+ const sample = body.slice(0, 800).trim().toLowerCase();
739
+ return sample.startsWith("<!doctype html") || sample.startsWith("<html") || sample.includes("<form") && (sample.includes("login") || sample.includes("authentik") || sample.includes("password"));
740
+ }
741
+ function jsonObject(text) {
742
+ try {
743
+ const parsed = JSON.parse(text);
744
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
745
+ return parsed;
746
+ } catch {
747
+ return void 0;
748
+ }
749
+ }
750
+ function hubEndpointShape(role, body) {
751
+ if (role === "health") return body.ok === true && typeof body.agents === "number";
752
+ return body.ok === true && (body.storage === "sqlite" || body.storage === "memory");
753
+ }
754
+ function classifyHubResponse(input) {
755
+ const contentType = contentTypeBase(input.contentType);
756
+ if (input.status >= 300 && input.status < 400) {
757
+ return {
758
+ ok: false,
759
+ status: input.status,
760
+ kind: "redirect",
761
+ detail: `HTTP ${input.status} redirect; this looks like an auth proxy or a redirect, not a kxm hub`,
762
+ body: { error: "auth_proxy", detail: `HTTP ${input.status} redirect` }
763
+ };
764
+ }
765
+ if (looksLikeHtml(contentType, input.bodyText)) {
766
+ return {
767
+ ok: false,
768
+ status: input.status,
769
+ kind: "auth_proxy",
770
+ detail: `HTTP ${input.status} returned an HTML login page${contentType ? ` (${contentType})` : ""}; this looks like an auth proxy, not a kxm hub`,
771
+ body: { error: "auth_proxy", detail: "HTML login page" }
772
+ };
773
+ }
774
+ const jsonType = contentType === "application/json" || contentType === "text/plain" || contentType === "";
775
+ const body = jsonType ? jsonObject(input.bodyText) : void 0;
776
+ if (body && hubEndpointShape(input.role, body) && input.status >= 200 && input.status < 300) {
777
+ if (contentType !== "" && contentType !== "application/json" && contentType !== "text/plain") {
778
+ return {
779
+ ok: false,
780
+ status: input.status,
781
+ kind: "not_hub",
782
+ detail: `HTTP ${input.status} content-type ${contentType} is not application/json`,
783
+ body: { error: "not_hub", detail: contentType }
784
+ };
785
+ }
786
+ return { ok: true, status: input.status, kind: "hub", detail: "kxm hub", body };
787
+ }
788
+ const typeNote = contentType && contentType !== "application/json" ? ` content-type ${contentType}` : "";
789
+ return {
790
+ ok: false,
791
+ status: input.status,
792
+ kind: "not_hub",
793
+ detail: `HTTP ${input.status}${typeNote} is not a kxm hub ${input.role} response`,
794
+ body: { error: "not_hub", detail: `HTTP ${input.status}${typeNote}` }
795
+ };
796
+ }
797
+ async function readHubEndpoint(url, role, fetchImpl, init = {}) {
798
+ try {
799
+ const response = await fetchImpl(`${url}/${role}`, {
800
+ ...init.headers ? { headers: init.headers } : {},
801
+ ...init.signal ? { signal: init.signal } : {},
802
+ redirect: "manual"
803
+ });
804
+ const bodyText = await response.text();
805
+ return classifyHubResponse({
806
+ role,
807
+ status: response.status,
808
+ contentType: response.headers.get("content-type"),
809
+ bodyText
810
+ });
811
+ } catch (error) {
812
+ if (isAbortError(error)) {
813
+ return { ok: false, status: 0, kind: "timeout", detail: "no reply within the probe budget", body: { error: "hub_unreachable" } };
814
+ }
815
+ return { ok: false, status: 0, kind: "unreachable", detail: "nothing answered", body: { error: "hub_unreachable" } };
816
+ }
817
+ }
675
818
  async function probeHubHealth(url, fetchImpl, timeoutMs = HUB_HEALTH_PROBE_MS) {
676
819
  const started = Date.now();
677
820
  const controller = new AbortController();
678
821
  const timer = setTimeout(() => controller.abort(), timeoutMs);
679
822
  try {
680
- const response = await fetchImpl(`${url}/health`, { signal: controller.signal });
681
- if (!response.ok) return { health: "unknown", probeMs: Date.now() - started };
682
- let body;
683
- try {
684
- body = await response.json();
685
- } catch {
686
- return { health: "unknown", probeMs: Date.now() - started };
687
- }
688
- if (body && typeof body === "object" && body.ok === true) {
689
- return { health: "on", probeMs: Date.now() - started };
690
- }
691
- return { health: "unknown", probeMs: Date.now() - started };
692
- } catch (error) {
693
- return { health: isAbortError(error) ? "unknown" : "off", probeMs: Date.now() - started };
823
+ const verdict = await readHubEndpoint(url, "health", fetchImpl, { signal: controller.signal });
824
+ const health = verdict.kind === "hub" ? "on" : verdict.kind === "unreachable" ? "off" : "unknown";
825
+ return { health, probeMs: Date.now() - started, kind: verdict.kind, detail: verdict.detail, status: verdict.status };
694
826
  } finally {
695
827
  clearTimeout(timer);
696
828
  }
697
829
  }
698
- var HUB_BINDING_SCHEMA, HUB_HEALTH_PROBE_MS, CloudTokenError, TOKEN_ENV_NAME, HubBindingError;
830
+ var HUB_BINDING_SCHEMA, HUB_HEALTH_PROBE_MS, CloudTokenError, TOKEN_ENV_NAME, HubBindingError, LEGACY_BINDING_KEYS, BINDING_KEYS;
699
831
  var init_hub_binding = __esm({
700
832
  "plugins/kxm/src/hub-binding.ts"() {
701
833
  "use strict";
@@ -716,6 +848,8 @@ var init_hub_binding = __esm({
716
848
  this.name = "HubBindingError";
717
849
  }
718
850
  };
851
+ LEGACY_BINDING_KEYS = ["schema", "url", "boundAt"];
852
+ BINDING_KEYS = /* @__PURE__ */ new Set([...LEGACY_BINDING_KEYS, "cloud", "tokenEnv", "tokenCommand"]);
719
853
  }
720
854
  });
721
855