@kontextmind/kxm 0.7.164 → 0.7.165

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 (36) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +9 -0
  3. package/README.md +2 -0
  4. package/docs/README.md +1 -0
  5. package/docs/architecture/inventory.md +4 -3
  6. package/docs/glossary.md +3 -3
  7. package/docs/operations/troubleshooting.md +3 -0
  8. package/docs/operations.md +154 -0
  9. package/docs/reference/cli-reference.md +4 -3
  10. package/docs/reference/configuration.md +11 -1
  11. package/package.json +1 -1
  12. package/plugins/kxm/.claude-plugin/plugin.json +2 -2
  13. package/plugins/kxm/README.md +2 -2
  14. package/plugins/kxm/dist/claude-hook.js +120 -16
  15. package/plugins/kxm/dist/cli.js +14929 -14542
  16. package/plugins/kxm/dist/extension.js +299 -122
  17. package/plugins/kxm/dist/mcp-server.js +7690 -106
  18. package/plugins/kxm/dist/runtime-supervisor.js +151 -4
  19. package/plugins/kxm/dist/runtime.js +158 -11
  20. package/plugins/kxm/package.json +1 -1
  21. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +5 -1
  22. package/plugins/kxm/src/cli/context-skills.ts +64 -10
  23. package/plugins/kxm/src/cli/hub.ts +128 -17
  24. package/plugins/kxm/src/cli/project.ts +7 -3
  25. package/plugins/kxm/src/cli/types.ts +6 -0
  26. package/plugins/kxm/src/cli/workflows.ts +12 -1
  27. package/plugins/kxm/src/cli.ts +23 -5
  28. package/plugins/kxm/src/extension.ts +7 -3
  29. package/plugins/kxm/src/hub-autostart.ts +3 -0
  30. package/plugins/kxm/src/hub-binding.ts +182 -2
  31. package/plugins/kxm/src/hub-env.ts +71 -0
  32. package/plugins/kxm/src/mcp-server.ts +3 -2
  33. package/plugins/kxm/src/project-name.ts +34 -7
  34. package/plugins/kxm/src/runtime-supervisor.ts +9 -3
  35. package/plugins/kxm/src/tenant-status.ts +9 -2
  36. package/scripts/build-runtime.mjs +5 -0
@@ -11,7 +11,7 @@
11
11
  "name": "kxm",
12
12
  "source": "./plugins/kxm",
13
13
  "description": "Durable workflows, peer agents, and kxm tui",
14
- "version": "0.7.164",
14
+ "version": "0.7.165",
15
15
  "category": "development",
16
16
  "tags": ["kxm", "multi-agent", "workflows", "mcp"]
17
17
  }
package/CHANGELOG.md CHANGED
@@ -6,6 +6,15 @@ All notable user-facing changes are documented here. The project follows [Semant
6
6
 
7
7
  ### Added
8
8
 
9
+ - **`kxm hub bind --cloud` attaches a client to a remote hub reached through a loopback forward.**
10
+ The binding records `--token-env` and/or `--token-command` and never the token.
11
+ Later commands, including the environment for a Pi worker, use that source and
12
+ do not fall back to this machine's hub-env token. Peer commands and
13
+ `kxm tenant status` resolve one project id: flag or `KXM_PROJECT`, then
14
+ `.kxm/project.yaml`, then `package.json`, then the directory name. The
15
+ SSH-forward recipe is in
16
+ [Operate a KXM hub](docs/operations.md#cross-box-peer-attach).
17
+
9
18
  - **A live agent step uses a configurable one-shot timeout, and a cancelling run recovers when its child has already exited.**
10
19
  The bound is the step `timeoutMs`, or the project `limits.agentStepTimeoutMs`
11
20
  when the step omits it (minimum 60 seconds, default one hour). A wider step
package/README.md CHANGED
@@ -105,6 +105,8 @@ You need Node.js 22.19 or newer on the 22.x line, or Node.js 24 or newer, plus G
105
105
  kxm hub view
106
106
  ```
107
107
 
108
+ A hub on another machine, reached through an SSH forward to loopback, uses `kxm hub bind --cloud` so this machine's hub-env token is not sent. The recipe is in [Operate a KXM hub](docs/operations.md#cross-box-peer-attach).
109
+
108
110
  Expected output:
109
111
 
110
112
  ```text
package/docs/README.md CHANGED
@@ -96,6 +96,7 @@ KXM connects coding agents through a durable, authenticated [hub](glossary.md#hu
96
96
 
97
97
  | Page | For | What you get |
98
98
  |---|---|---|
99
+ | [Operate a KXM hub](operations.md) | Hub operators | Index of the operations guides, and cross-box attach over an SSH forward |
99
100
  | [Deploy KXM](operations/deploy.md) | Hub operators | Deployment classes, supervision, and hosted per-tenant hubs behind a proxy |
100
101
  | [Monitor KXM](operations/monitoring.md) | Hub operators | `kxm dash`, health and readiness, metrics, logs and alerts |
101
102
  | [Back up and restore KXM](operations/backup-and-restore.md) | Hub operators | `kxm backup` and `kxm restore`, the state roots, and a stopped-hub recipe |
@@ -32,9 +32,10 @@ On macOS the user state root is `Library/Application Support/KXM` under the
32
32
  home directory (`kxmUserStateRoot` in `plugins/kxm/src/bindings.ts`). Windows
33
33
  uses `AppData/Local/KXM`. Other platforms use `$XDG_STATE_HOME/kxm` or
34
34
  `.local/state/kxm`. `KXM_STATE_HOME` overrides that root when it is absolute.
35
- The tracker mentions `docs/operations.md` for the six backup roots. That file
36
- is not in this checkout. The six roots are described in
37
- [Backup and restore](../operations/backup-and-restore.md).
35
+ The tracker mentions `docs/operations.md` for the six backup roots. That page
36
+ indexes the operations guides and links the six roots to
37
+ [Backup and restore](../operations/backup-and-restore.md). It also holds
38
+ [Cross-box peer attach](../operations.md#cross-box-peer-attach).
38
39
 
39
40
  ## Related
40
41
 
package/docs/glossary.md CHANGED
@@ -38,7 +38,7 @@ The service that authenticates [agents](#agent), stores [messages](#message) and
38
38
 
39
39
  ### Hub binding
40
40
 
41
- The record that `kxm hub bind <url>` writes to `hub-binding.json` in the [user state root](#user-state-root), naming the hub this machine uses. `kxm hub view` checks it, and the Runtime syncs to it. Binding a remote hub requires a credential.
41
+ The record that `kxm hub bind <url>` writes to `hub-binding.json` in the [user state root](#user-state-root), naming the hub this machine uses. `kxm hub view` checks it, and the Runtime syncs to it. Binding a remote hub requires a credential. `kxm hub bind --cloud` marks a loopback forward as remote and records a token variable or command, never the token.
42
42
 
43
43
  ### KontextMind
44
44
 
@@ -104,11 +104,11 @@ Another [agent](#agent) in the same [project](#project). `kxm_list` and `kxm pee
104
104
 
105
105
  ### Project
106
106
 
107
- The authentication and discovery namespace on the hub. An agent names it with `KXM_PROJECT` (otherwise the `package.json` name, then the directory name), and its key in the hub's `KXM_PROJECT_TOKENS` holds its token. Agents see only peers in the same project. Compare [project ID](#project-id).
107
+ The authentication and discovery namespace on the hub. Every command resolves it the same way: `--project` or `KXM_PROJECT`, then the [project ID](#project-id) in `.kxm/project.yaml`, then the `package.json` name, then the directory name. Its key in the hub's `KXM_PROJECT_TOKENS` holds its token. Agents see only peers in the same project.
108
108
 
109
109
  ### Project ID
110
110
 
111
- The stable `prj_` identifier in `.kxm/project.yaml`. The [Runtime](#runtime) and hub sync use it. It is not the hub [project](#project) name, even though both describe the same body of work.
111
+ The stable `prj_` identifier in `.kxm/project.yaml`. It is the hub [project](#project) name unless `--project` or `KXM_PROJECT` overrides it. The [Runtime](#runtime) store keeps this id as the local key.
112
112
 
113
113
  ### Project token
114
114
 
@@ -87,6 +87,9 @@ If a malformed claim remains, read the exact `.pid` JSON in the state directory
87
87
  | `hub_url_invalid` | Use an `http` or `https` URL without user info, query or fragment |
88
88
  | `hub_bind_unauthenticated` | The URL is remote and this machine has no token for the project; export it and bind again |
89
89
  | `hub_credential_unreadable` | Repair or remove `hub-env.json` under the user state root |
90
+ | `cloud_token_missing` | A `--cloud` binding's variable is empty and it has no working command. Export the variable or fix `--token-command`. This machine's hub-env token is not sent |
91
+ | `cloud_flag_required` | `--token-env` or `--token-command` was passed without `--cloud` |
92
+ | 401 after binding a loopback forward | The bind was not `--cloud`, so the client sent the local hub-env token. See [Cross-box peer attach](../operations.md#cross-box-peer-attach) |
90
93
 
91
94
  ## Claude Code plugin and MCP
92
95
 
@@ -0,0 +1,154 @@
1
+ # Operate a KXM hub
2
+
3
+ This page is the operator index for a [hub](glossary.md#hub): where the other
4
+ operations guides live, and how to attach a client on another machine to a hub
5
+ that is not reachable from the internet. The client in that setup is a local
6
+ `kxm` CLI, plus the Pi and Claude Code agents that CLI starts.
7
+
8
+ ## Where each procedure lives
9
+
10
+ | Procedure | Page |
11
+ |---|---|
12
+ | Install the services on a tenant box, including loopback listeners | [Deploy KXM](operations/deploy.md) |
13
+ | Health, readiness, metrics, logs, and alerts | [Monitor KXM](operations/monitoring.md) |
14
+ | Stopped-state backup of all six state roots, and restore | [Back up and restore KXM](operations/backup-and-restore.md) |
15
+ | Package update, plugin reinstall, and schema ceilings | [Upgrade KXM](operations/upgrade.md) |
16
+ | Runtime outbox, sync refusals, and hub leases | [Operate Runtime sync and leases](operations/runtime-sync.md) |
17
+ | Symptoms, causes, and fixes | [Troubleshoot KXM](operations/troubleshooting.md) |
18
+
19
+ The six roots are the checkout, the workspace, workspace state, the user state
20
+ root, user config, and federated telemetry. The stopped-state copy of each one
21
+ is in [Back up and restore KXM](operations/backup-and-restore.md).
22
+
23
+ ## Before you begin
24
+
25
+ - The `kxm` CLI is installed on the client machine.
26
+ - You can open SSH to the hub host, and you already have that host's key.
27
+ - The hub's admin token is in a password manager. 1Password CLI (`op`) is the
28
+ usual source. The token is never written into `hub-binding.json`.
29
+
30
+ ## Cross-box peer attach
31
+
32
+ Use this when the hub listens on loopback on a private machine and your client
33
+ reaches it through an SSH local forward. A plain `kxm hub bind` of
34
+ `http://127.0.0.1:17331` is a loopback bind: it stores no credential, and later
35
+ commands send this machine's hub-env admin token. The remote hub rejects that
36
+ token with 401. `--cloud` marks the binding remote even though the URL is
37
+ loopback, and names where the token comes from without storing the token.
38
+
39
+ ### Open the forward with a pinned host key
40
+
41
+ Pin the host key before the first connection. Do not accept a key on first use.
42
+
43
+ ```bash
44
+ ssh-keyscan -H 10.31.0.21 >> ~/.ssh/known_hosts
45
+ ssh -N \
46
+ -o ExitOnForwardFailure=yes \
47
+ -o StrictHostKeyChecking=yes \
48
+ -o UserKnownHostsFile="$HOME/.ssh/known_hosts" \
49
+ -L 127.0.0.1:17331:127.0.0.1:7331 \
50
+ user@10.31.0.21
51
+ ```
52
+
53
+ Leave that SSH session open. Hub traffic then goes to `127.0.0.1:17331` on the
54
+ client and comes out on the hub host's loopback port 7331.
55
+
56
+ <details><summary>PowerShell</summary>
57
+
58
+ ```powershell
59
+ ssh-keyscan -H 10.31.0.21 >> $env:USERPROFILE\.ssh\known_hosts
60
+ ssh -N `
61
+ -o ExitOnForwardFailure=yes `
62
+ -o StrictHostKeyChecking=yes `
63
+ -o UserKnownHostsFile="$env:USERPROFILE\.ssh\known_hosts" `
64
+ -L 127.0.0.1:17331:127.0.0.1:7331 `
65
+ user@10.31.0.21
66
+ ```
67
+
68
+ </details>
69
+
70
+ ### Bind the forward as a cloud hub
71
+
72
+ Pick one token source. The environment variable is enough when the shell
73
+ already holds the token. The command form asks 1Password each time a command
74
+ needs the token. Do not put the token text in `--token-command`: that string
75
+ is stored in the binding.
76
+
77
+ ```bash
78
+ export KXMD_HUB_TOKEN="$(op read 'op://Private/kxmd/admin-token')"
79
+ kxm hub bind --cloud --token-env KXMD_HUB_TOKEN http://127.0.0.1:17331
80
+ kxm hub view
81
+ ```
82
+
83
+ Or name the 1Password command and skip the variable:
84
+
85
+ ```bash
86
+ kxm hub bind --cloud \
87
+ --token-command "op read op://Private/kxmd/admin-token" \
88
+ http://127.0.0.1:17331
89
+ ```
90
+
91
+ Expected output:
92
+
93
+ ```text
94
+ bound hub http://127.0.0.1:17331 · remote · health=on · token is not stored
95
+ hub health=true ready=true · remote hub
96
+ ```
97
+
98
+ `--token-env` and `--token-command` can be combined. A non-empty variable wins.
99
+ Otherwise the command runs. The command is a program and arguments, not a shell
100
+ pipeline (`|`, `&`, `;`, and redirection are refused).
101
+
102
+ <details><summary>PowerShell</summary>
103
+
104
+ ```powershell
105
+ $env:KXMD_HUB_TOKEN = op read "op://Private/kxmd/admin-token"
106
+ kxm hub bind --cloud --token-env KXMD_HUB_TOKEN http://127.0.0.1:17331
107
+ kxm hub view
108
+ ```
109
+
110
+ </details>
111
+
112
+ ### What --cloud changes
113
+
114
+ | | Local bind | `--cloud` bind |
115
+ |---|---|---|
116
+ | 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 this machine's hub-env | The named source only. Hub-env is not a fallback |
119
+ | Missing source | Agent commands report `project_token_missing` | `cloud_token_missing` or `cloud_token_command_failed`. The local admin token is not sent |
120
+ | `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
+
122
+ `kxm hub view`, `kxm tenant status`, `kxm peer`, `kxm workflow` agent commands,
123
+ `kxm context`, Runtime presence, and the environment exported for a Pi worker
124
+ all use that source. `kxm agent worker` puts `KXM_AUTH_TOKEN` in the child
125
+ process environment only. It does not write the token to disk.
126
+
127
+ The hub project id is the same value for peer commands and `kxm tenant status`:
128
+ `--project` or `KXM_PROJECT`, then the `id` in `.kxm/project.yaml`, then
129
+ `package.json` `name`, then the directory name.
130
+
131
+ ### Remove the binding
132
+
133
+ ```bash
134
+ kxm hub unbind
135
+ ```
136
+
137
+ Unbind deletes `hub-binding.json`. It does not delete hub-env, and it does not
138
+ close the SSH forward. Stop the forward by ending the `ssh -N` session.
139
+
140
+ ## Troubleshooting
141
+
142
+ | Symptom | Cause | Fix |
143
+ |---|---|---|
144
+ | 401 `invalid_auth` after binding `127.0.0.1:17331` | The bind was not `--cloud`, so the client sent this machine's hub-env admin token | Bind again with `--cloud` and a token source that holds the remote hub's token. Unset a stale `KXM_AUTH_TOKEN` that points at the local hub |
145
+ | `cloud_token_missing` | `KXMD_HUB_TOKEN` (or the variable you named) is unset or empty, and no command is configured | Export the variable or pass `--token-command`. The local hub-env token is intentionally unused |
146
+ | `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 |
147
+ | 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 |
148
+ | `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 |
149
+
150
+ ## Next steps
151
+
152
+ - Deploy the hub itself: [Deploy KXM](operations/deploy.md)
153
+ - Read health and metrics: [Monitor KXM](operations/monitoring.md)
154
+ - Command flags: [kxm hub bind](reference/cli-reference.md#kxm-hub-bind)
@@ -569,16 +569,17 @@ kxm hub stop --wait-ms 8000 --json
569
569
  ### `kxm hub bind`
570
570
 
571
571
  ```text
572
- kxm hub bind <url>
572
+ kxm hub bind <url> [--cloud] [--token-env <name>] [--token-command <command>]
573
573
  ```
574
574
 
575
575
  Binds this machine to a running hub by writing `hub-binding.json` under the user state root, then probes the hub's health for up to 300 ms. Every hub client uses the binding when `KXM_SERVER_URL` is unset.
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
- - 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).
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
+ - `--cloud` marks the binding remote even when the URL is a loopback forward. It requires `--token-env`, `--token-command`, or both. The token is not written. A non-empty variable wins; otherwise the command runs. Source flags without `--cloud` fail with `cloud_flag_required`. A missing 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).
580
581
  - Mutates the binding file. `--dry-run` validates and prints the plan without writing.
581
- - JSON keys: `url`, `scope`, `file`, `health` (`on`, `off`, or `unknown`), `probeMs`.
582
+ - JSON keys: `url`, `scope`, `file`, `health` (`on`, `off`, or `unknown`), `probeMs`. A cloud bind also returns `cloud`, and `tokenEnv` or `tokenCommand` when set.
582
583
 
583
584
  ```bash
584
585
  kxm hub bind http://127.0.0.1:7331 --dry-run
@@ -108,7 +108,7 @@ Every agent client (the Pi extension, the Claude Code MCP server, and the `kxm p
108
108
  |---|---|---|
109
109
  | `KXM_SERVER_URL` | `http://127.0.0.1:7331` | Hub base URL. For the CLI, the machine's `kxm hub bind` URL is used when this is unset |
110
110
  | `KXM_AUTH_TOKEN` | See below | The project token for this agent's project |
111
- | `KXM_PROJECT` | `name` in `package.json` in the working directory, else the directory name | Project for discovery and authentication |
111
+ | `KXM_PROJECT` | `id` in the nearest `.kxm/project.yaml`, else `name` in `package.json` in the working directory, else the directory name | Project for discovery and authentication. An explicit `--project` flag on `kxm peer list` and `kxm tenant status` wins over this variable. The same resolver is used by peer commands, tenant status, workers, the Pi extension, and the Claude Code MCP server |
112
112
  | `KXM_AGENT_NAME` | Harness-specific (see below) | Name peers see; unique among online agents in the project, compared case-insensitively |
113
113
  | `KXM_AGENT_PURPOSE` | Harness-specific (see below) | One line peers use to decide what to send this agent |
114
114
 
@@ -120,6 +120,16 @@ Every agent client (the Pi extension, the Claude Code MCP server, and the `kxm p
120
120
 
121
121
  A clean shutdown marks an identity offline. Reconnecting with the same project and name resumes its durable agent ID and rotates its agent key. If the Claude Code name is already online in the project, the MCP server registers once more as `<name>-<pid>`.
122
122
 
123
+ A `--cloud` hub binding replaces this table's token lookup for every client, including agents. The token comes from the binding's `--token-env` or `--token-command`, never from this machine's hub-env file, and it is not written to disk. `kxm agent worker` copies it into the child process environment as `KXM_AUTH_TOKEN`.
124
+
125
+ Also check plugin.json is valid JSON.
126
+
127
+ And configuration table cell might be very long. That's ok.
128
+
129
+ Look at harness table "never the admin token" - the new paragraph clarifies cloud. Good.
130
+
131
+ Plugin README add one sentence under which token.
132
+
123
133
  ## Claude Code plugin settings
124
134
 
125
135
  The plugin asks for `server_url`, `auth_token`, `agent_name`, `agent_purpose` and `project` when you install it, and passes them to its MCP server as `KXM_SERVER_URL`, `KXM_AUTH_TOKEN`, `KXM_AGENT_NAME`, `KXM_AGENT_PURPOSE` and `KXM_PROJECT`. It also sets `KXM_PROJECT_DIR` to the directory Claude Code started in. Change them with `/plugin configure kxm@kxm`. Every field, default and token rule is in [Claude Code plugin settings](config-reference.md#claude-code-plugin-settings) and the [plugin README](../../plugins/kxm/README.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontextmind/kxm",
3
- "version": "0.7.164",
3
+ "version": "0.7.165",
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.164",
5
+ "version": "0.7.165",
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",
@@ -65,7 +65,7 @@
65
65
  "project": {
66
66
  "type": "string",
67
67
  "title": "Project",
68
- "description": "Hub project key; must match a key in the hub KXM_PROJECT_TOKENS. Blank uses the package.json name, then the directory name",
68
+ "description": "Hub project key; must match a key in the hub KXM_PROJECT_TOKENS. Blank uses the .kxm/project.yaml id, then the package.json name, then the directory name",
69
69
  "required": false
70
70
  }
71
71
  }
@@ -58,7 +58,7 @@ Claude Code asks for these options when you install the plugin. Change them late
58
58
  | `auth_token` | `KXM_AUTH_TOKEN` | Blank | This project's token from the hub's `KXM_PROJECT_TOKENS`. Leave it blank on the machine that runs the hub. Never the hub admin token. Marked sensitive. |
59
59
  | `agent_name` | `KXM_AGENT_NAME` | `claude` | Name other agents see. The first active session in a project keeps it; a later concurrent session registers as `<name>-<pid>`. |
60
60
  | `agent_purpose` | `KXM_AGENT_PURPOSE` | `Claude Code implementation and review agent` | One line that peers use to decide what to send this agent. |
61
- | `project` | `KXM_PROJECT` | Blank | Hub project key; must match a key in the hub's `KXM_PROJECT_TOKENS`. Blank uses `name` from the project's `package.json`, then the directory name. |
61
+ | `project` | `KXM_PROJECT` | Blank | Hub project key; must match a key in the hub's `KXM_PROJECT_TOKENS`. Blank uses the `id` in `.kxm/project.yaml`, then `name` from `package.json`, then the directory name. |
62
62
 
63
63
  The MCP server also receives `KXM_PROJECT_DIR`, set to the directory Claude Code was started in (`CLAUDE_PROJECT_DIR`). It decides the default project key and whether this is a KXM project (one with a `.kxm/` directory).
64
64
 
@@ -67,7 +67,7 @@ The MCP server also receives `KXM_PROJECT_DIR`, set to the directory Claude Code
67
67
  The plugin acts as an agent of one hub project and authenticates with that project's token. It never uses the hub admin token.
68
68
 
69
69
  - On the machine that runs the hub, leave `auth_token` blank. The MCP server then uses the project token the hub saved for this project in `hub-env.json` under the user state root (`KXM_STATE_HOME`, or the platform default listed in [State outside the project](../../docs/reference/config-reference.md#state-outside-the-project)). It uses only that entry, never the admin token saved beside it.
70
- - On any other machine, enter this project's token at `/plugin configure kxm@kxm`. Get it from whoever runs the hub, through your password manager.
70
+ - On any other machine, enter this project's token at `/plugin configure kxm@kxm`. Get it from whoever runs the hub, through your password manager. When the machine is bound with `kxm hub bind --cloud`, leave `auth_token` blank: the MCP server reads the binding's token source and does not store the token. The recipe is [Cross-box peer attach](../../docs/operations.md#cross-box-peer-attach).
71
71
  - Never enter the hub admin token. It is the operator's credential. The hub accepts it for any project that has no token of its own, so an agent holding it could act in projects it was never given.
72
72
  - Without a project token, every `kxm_*` tool fails with `KXM has no project token for project <p> on this machine`, and the MCP server does not contact the hub.
73
73
 
@@ -568,6 +568,47 @@ function validateHubUrl(raw) {
568
568
  }
569
569
  return parsed.href.replace(/\/$/, "");
570
570
  }
571
+ function splitTokenCommand(raw) {
572
+ if (/[|&;<>$`\n\r]/.test(raw)) {
573
+ throw new CloudTokenError(
574
+ "cloud_token_command_invalid",
575
+ "token command must be a program and arguments, not a shell pipeline"
576
+ );
577
+ }
578
+ const args = [];
579
+ let current = "";
580
+ let quote;
581
+ for (const ch of raw) {
582
+ if (quote) {
583
+ if (ch === quote) quote = void 0;
584
+ else current += ch;
585
+ continue;
586
+ }
587
+ if (ch === "'" || ch === '"') {
588
+ quote = ch;
589
+ continue;
590
+ }
591
+ if (ch === " " || ch === " ") {
592
+ if (current.length > 0) {
593
+ args.push(current);
594
+ current = "";
595
+ }
596
+ continue;
597
+ }
598
+ current += ch;
599
+ }
600
+ if (quote) {
601
+ throw new CloudTokenError("cloud_token_command_invalid", "token command has an unclosed quote");
602
+ }
603
+ if (current.length > 0) args.push(current);
604
+ if (args.length === 0) {
605
+ throw new CloudTokenError("cloud_token_command_invalid", "token command is empty");
606
+ }
607
+ return args;
608
+ }
609
+ function isCloudTokenEnvName(value) {
610
+ return TOKEN_ENV_NAME.test(value);
611
+ }
571
612
  function isIsoTimestamp(value) {
572
613
  if (Number.isNaN(Date.parse(value))) return false;
573
614
  return value === new Date(value).toISOString();
@@ -591,7 +632,9 @@ function readHubBinding(env = process.env) {
591
632
  }
592
633
  const row = parsed;
593
634
  const keys = Object.keys(row);
594
- if (keys.length !== 3 || row.schema !== HUB_BINDING_SCHEMA || typeof row.url !== "string" || typeof row.boundAt !== "string" || !isIsoTimestamp(row.boundAt)) {
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)) {
595
638
  throw new HubBindingError(`malformed hub binding at ${file}`);
596
639
  }
597
640
  let url;
@@ -600,7 +643,34 @@ function readHubBinding(env = process.env) {
600
643
  } catch {
601
644
  throw new HubBindingError(`malformed hub binding at ${file}`);
602
645
  }
603
- return { schema: HUB_BINDING_SCHEMA, url, boundAt: row.boundAt };
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}`);
648
+ let tokenEnv;
649
+ let tokenCommand;
650
+ if (row.tokenEnv !== void 0) {
651
+ if (typeof row.tokenEnv !== "string" || !isCloudTokenEnvName(row.tokenEnv)) {
652
+ throw new HubBindingError(`malformed hub binding at ${file}`);
653
+ }
654
+ tokenEnv = row.tokenEnv;
655
+ }
656
+ if (row.tokenCommand !== void 0) {
657
+ if (typeof row.tokenCommand !== "string") throw new HubBindingError(`malformed hub binding at ${file}`);
658
+ try {
659
+ splitTokenCommand(row.tokenCommand);
660
+ } catch {
661
+ throw new HubBindingError(`malformed hub binding at ${file}`);
662
+ }
663
+ tokenCommand = row.tokenCommand;
664
+ }
665
+ if (!tokenEnv && !tokenCommand) throw new HubBindingError(`malformed hub binding at ${file}`);
666
+ return {
667
+ schema: HUB_BINDING_SCHEMA,
668
+ url,
669
+ boundAt: row.boundAt,
670
+ cloud: true,
671
+ ...tokenEnv ? { tokenEnv } : {},
672
+ ...tokenCommand ? { tokenCommand } : {}
673
+ };
604
674
  }
605
675
  async function probeHubHealth(url, fetchImpl, timeoutMs = HUB_HEALTH_PROBE_MS) {
606
676
  const started = Date.now();
@@ -625,12 +695,21 @@ async function probeHubHealth(url, fetchImpl, timeoutMs = HUB_HEALTH_PROBE_MS) {
625
695
  clearTimeout(timer);
626
696
  }
627
697
  }
628
- var HUB_BINDING_SCHEMA, HUB_HEALTH_PROBE_MS, HubBindingError;
698
+ var HUB_BINDING_SCHEMA, HUB_HEALTH_PROBE_MS, CloudTokenError, TOKEN_ENV_NAME, HubBindingError;
629
699
  var init_hub_binding = __esm({
630
700
  "plugins/kxm/src/hub-binding.ts"() {
631
701
  "use strict";
632
702
  HUB_BINDING_SCHEMA = "kxm.hub-binding.v1";
633
703
  HUB_HEALTH_PROBE_MS = 300;
704
+ CloudTokenError = class extends Error {
705
+ code;
706
+ constructor(code, message) {
707
+ super(message);
708
+ this.name = "CloudTokenError";
709
+ this.code = code;
710
+ }
711
+ };
712
+ TOKEN_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
634
713
  HubBindingError = class extends Error {
635
714
  constructor(message) {
636
715
  super(message);
@@ -8770,11 +8849,15 @@ var project_name_exports = {};
8770
8849
  __export(project_name_exports, {
8771
8850
  defaultProjectName: () => defaultProjectName
8772
8851
  });
8773
- import { readFileSync as readFileSync7 } from "node:fs";
8774
- import { basename as basename2, join as join6 } from "node:path";
8775
- function defaultProjectName(cwd, env = process.env) {
8852
+ import { existsSync as existsSync7, readFileSync as readFileSync7 } from "node:fs";
8853
+ import { basename as basename2, dirname as dirname3, join as join6, resolve as resolve5 } from "node:path";
8854
+ function defaultProjectName(cwd, env = process.env, explicit) {
8855
+ const fromExplicit = explicit?.trim();
8856
+ if (fromExplicit) return fromExplicit;
8776
8857
  const fromEnv = env.KXM_PROJECT?.trim();
8777
8858
  if (fromEnv) return fromEnv;
8859
+ const fromProject = readProjectYamlId(cwd);
8860
+ if (fromProject) return fromProject;
8778
8861
  try {
8779
8862
  const pkg = JSON.parse(readFileSync7(join6(cwd, "package.json"), "utf8"));
8780
8863
  if (typeof pkg.name === "string" && pkg.name.trim().length > 0) return pkg.name.trim();
@@ -8782,9 +8865,30 @@ function defaultProjectName(cwd, env = process.env) {
8782
8865
  }
8783
8866
  return basename2(cwd);
8784
8867
  }
8868
+ function readProjectYamlId(start) {
8869
+ let dir = resolve5(start);
8870
+ for (; ; ) {
8871
+ const file = join6(dir, ".kxm", "project.yaml");
8872
+ if (existsSync7(file)) {
8873
+ try {
8874
+ const doc = (0, import_yaml2.parse)(readFileSync7(file, "utf8"));
8875
+ if (doc && typeof doc === "object" && !Array.isArray(doc) && typeof doc.id === "string" && doc.id.trim()) {
8876
+ return doc.id.trim();
8877
+ }
8878
+ } catch {
8879
+ }
8880
+ return void 0;
8881
+ }
8882
+ const parent = dirname3(dir);
8883
+ if (parent === dir) return void 0;
8884
+ dir = parent;
8885
+ }
8886
+ }
8887
+ var import_yaml2;
8785
8888
  var init_project_name = __esm({
8786
8889
  "plugins/kxm/src/project-name.ts"() {
8787
8890
  "use strict";
8891
+ import_yaml2 = __toESM(require_dist(), 1);
8788
8892
  }
8789
8893
  });
8790
8894
 
@@ -8930,9 +9034,9 @@ __export(commands_exports, {
8930
9034
  timingSafeStringCompare: () => timingSafeStringCompare
8931
9035
  });
8932
9036
  import { createHash, randomUUID as randomUUID3, timingSafeEqual } from "node:crypto";
8933
- import { chmodSync, existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync8, unlinkSync, writeFileSync as writeFileSync5 } from "node:fs";
9037
+ import { chmodSync, existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync8, unlinkSync, writeFileSync as writeFileSync5 } from "node:fs";
8934
9038
  import { homedir as homedir3 } from "node:os";
8935
- import { dirname as dirname3, join as join7, resolve as resolve5 } from "node:path";
9039
+ import { dirname as dirname4, join as join7, resolve as resolve6 } from "node:path";
8936
9040
  function forwardedHops(handling) {
8937
9041
  if (!handling?.length) return void 0;
8938
9042
  return {
@@ -9114,15 +9218,15 @@ function parseSessionToken(token) {
9114
9218
  return void 0;
9115
9219
  }
9116
9220
  function resolveUserConfigDirectory(overrideDir) {
9117
- if (overrideDir) return resolve5(overrideDir);
9118
- return resolve5(process.env.KXM_USER_CONFIG_DIR?.trim() || join7(homedir3(), ".config", "kxm"));
9221
+ if (overrideDir) return resolve6(overrideDir);
9222
+ return resolve6(process.env.KXM_USER_CONFIG_DIR?.trim() || join7(homedir3(), ".config", "kxm"));
9119
9223
  }
9120
9224
  function sessionTokenPath(userConfigDir) {
9121
9225
  return join7(resolveUserConfigDirectory(userConfigDir), "session.token");
9122
9226
  }
9123
9227
  function persistSessionTokenToDisk(token, options) {
9124
9228
  const filePath = sessionTokenPath(options?.userConfigDir);
9125
- const dir = dirname3(filePath);
9229
+ const dir = dirname4(filePath);
9126
9230
  mkdirSync6(dir, { recursive: true, mode: 448 });
9127
9231
  const mode = options?.mode ?? 384;
9128
9232
  writeFileSync5(filePath, `${token.trim()}
@@ -9135,7 +9239,7 @@ function persistSessionTokenToDisk(token, options) {
9135
9239
  }
9136
9240
  function readSessionTokenFromDisk(options) {
9137
9241
  const filePath = sessionTokenPath(options?.userConfigDir);
9138
- if (!existsSync7(filePath)) return void 0;
9242
+ if (!existsSync8(filePath)) return void 0;
9139
9243
  try {
9140
9244
  const token = readFileSync8(filePath, "utf8").trim();
9141
9245
  if (!token) return void 0;
@@ -9148,7 +9252,7 @@ function readSessionTokenFromDisk(options) {
9148
9252
  }
9149
9253
  function clearSessionTokenFromDisk(options) {
9150
9254
  const filePath = sessionTokenPath(options?.userConfigDir);
9151
- if (!existsSync7(filePath)) return false;
9255
+ if (!existsSync8(filePath)) return false;
9152
9256
  try {
9153
9257
  unlinkSync(filePath);
9154
9258
  return true;
@@ -9247,7 +9351,7 @@ function enforceToolPolicy(commandName, env = process.env, options) {
9247
9351
  return { allowed: true };
9248
9352
  }
9249
9353
  const tokenFile = sessionTokenPath(env.KXM_USER_CONFIG_DIR);
9250
- if (existsSync7(tokenFile)) {
9354
+ if (existsSync8(tokenFile)) {
9251
9355
  let tokenRaw;
9252
9356
  try {
9253
9357
  tokenRaw = readFileSync8(tokenFile, "utf8").trim();
@@ -9937,7 +10041,7 @@ var init_commands = __esm({
9937
10041
 
9938
10042
  // plugins/kxm/src/claude-hook.ts
9939
10043
  import { readFileSync as readFileSync9, statSync } from "node:fs";
9940
- import { join as join8, resolve as resolve6 } from "node:path";
10044
+ import { join as join8, resolve as resolve7 } from "node:path";
9941
10045
 
9942
10046
  // plugins/kxm/src/session-token-hint.ts
9943
10047
  var ENV_TEXT = "KXM_SESSION_TOKEN in the environment Claude Code was launched from is malformed or expired, so every kxm_* tool fails with tool_policy_denied. Ask the user to unset or replace KXM_SESSION_TOKEN in the environment Claude Code was launched from, then restart Claude Code.";
@@ -10010,7 +10114,7 @@ function truncate2(text, max) {
10010
10114
  }
10011
10115
  async function sessionStartContext(input, env) {
10012
10116
  const inputCwd = typeof input.cwd === "string" ? input.cwd.trim() : "";
10013
- const root = resolve6(env.CLAUDE_PROJECT_DIR?.trim() || inputCwd || process.cwd());
10117
+ const root = resolve7(env.CLAUDE_PROJECT_DIR?.trim() || inputCwd || process.cwd());
10014
10118
  if (!isDirectory(join8(root, ".kxm"))) return void 0;
10015
10119
  const [
10016
10120
  { resolveSessionHubStatus: resolveSessionHubStatus2 },