diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 86dfaf20025..4e71df65aca 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -54,7 +54,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Write install stamp
- run: python3 scripts/write_install_stamp.py --output install-stamp.json
+ run: python3 scripts/write_install_stamp.py --output install-stamp.json --distribution docker
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
@@ -145,7 +145,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Write install stamp
- run: python3 scripts/write_install_stamp.py --output install-stamp.json
+ run: python3 scripts/write_install_stamp.py --output install-stamp.json --distribution docker
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts
index ce8aa7d8382..e763d801aa9 100644
--- a/apps/desktop/electron/main.ts
+++ b/apps/desktop/electron/main.ts
@@ -440,7 +440,7 @@ const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..')
// Schema:
// schema 1: { commit, branch, builtAt, dirty, source }
// schema 2 adds immutable-package provenance: baseVersion, displayVersion,
-// distance, and installMethod.
+// distance, and distribution.
const INSTALL_STAMP_SCHEMA_VERSION = 2
function loadInstallStamp() {
@@ -474,7 +474,7 @@ function loadInstallStamp() {
baseVersion: typeof parsed.baseVersion === 'string' ? parsed.baseVersion : null,
displayVersion: typeof parsed.displayVersion === 'string' ? parsed.displayVersion : null,
distance: typeof parsed.distance === 'number' && parsed.distance >= 0 ? parsed.distance : null,
- installMethod: typeof parsed.installMethod === 'string' ? parsed.installMethod : null,
+ distribution: typeof parsed.distribution === 'string' ? parsed.distribution : null,
builtAt: parsed.builtAt || null,
dirty: Boolean(parsed.dirty),
source: parsed.source || null,
@@ -11071,7 +11071,7 @@ function resolveHermesVersionInfo() {
commit: INSTALL_STAMP.commit,
branch: INSTALL_STAMP.branch,
source: INSTALL_STAMP.source ?? undefined,
- installMethod: INSTALL_STAMP.installMethod ?? undefined,
+ distribution: INSTALL_STAMP.distribution ?? undefined,
dirty: INSTALL_STAMP.dirty
}
}
diff --git a/apps/desktop/src/components/version-details.test.tsx b/apps/desktop/src/components/version-details.test.tsx
index a720d80d565..11ec9bfa464 100644
--- a/apps/desktop/src/components/version-details.test.tsx
+++ b/apps/desktop/src/components/version-details.test.tsx
@@ -38,4 +38,28 @@ describe('VersionDetails', () => {
expect(screen.getByText('unknown')).toBeTruthy()
expect(screen.queryByText('No branch information')).toBeNull()
})
+
+ it('shows the Nix source and distribution from the stamp', () => {
+ render(
+
+
+
+ )
+
+ expect(screen.getByText('Source')).toBeTruthy()
+ expect(screen.getAllByText('Nix')).toHaveLength(2)
+ expect(screen.getByText('Distribution')).toBeTruthy()
+ })
+
+ it('distinguishes CI provenance from the Docker distribution', () => {
+ render(
+
+
+
+ )
+
+ expect(screen.getByText('CI')).toBeTruthy()
+ expect(screen.getByText('Distribution')).toBeTruthy()
+ expect(screen.getByText('Docker')).toBeTruthy()
+ })
})
diff --git a/apps/desktop/src/components/version-details.tsx b/apps/desktop/src/components/version-details.tsx
index 498ace7ede2..f70143fe145 100644
--- a/apps/desktop/src/components/version-details.tsx
+++ b/apps/desktop/src/components/version-details.tsx
@@ -12,6 +12,8 @@ export function VersionDetails({ version }: { version: DesktopVersionInfo }) {
const { t } = useI18n()
const u = t.updates
const unknownDistance = version.dirty && version.distance == null
+ const source = version.source === 'ci' ? 'CI' : version.source ? version.source[0].toUpperCase() + version.source.slice(1) : null
+ const distribution = version.distribution === 'nix' ? 'Nix' : version.distribution === 'docker' ? 'Docker' : null
return (
@@ -40,6 +42,18 @@ export function VersionDetails({ version }: { version: DesktopVersionInfo }) {
)}
+ {source && (
+
+
- {u.versionDetailsSource}
+ - {source}
+
+ )}
+ {distribution && (
+
+
- {u.versionDetailsDistribution}
+ - {distribution}
+
+ )}
{version.dirty && (
{unknownDistance ? u.versionDetailsDirtyUnknown : u.versionDetailsDirty}
)}
diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts
index 241d558e23d..f90d6fa2828 100644
--- a/apps/desktop/src/global.d.ts
+++ b/apps/desktop/src/global.d.ts
@@ -328,7 +328,7 @@ export interface DesktopVersionInfo {
nodeVersion: string
platform: string
hermesRoot: string
- installMethod?: string
+ distribution?: 'docker' | 'nix'
source?: 'build' | 'ci' | 'docker' | 'fallback' | 'git' | 'local' | 'nix' | 'unknown'
}
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 6746449611b..cd9b01eb956 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -2134,6 +2134,8 @@ export const en: Translations = {
versionDetailsBranch: 'Branch',
versionDetailsNoBranchInfo: 'No branch information',
versionDetailsCommit: 'Commit',
+ versionDetailsSource: 'Source',
+ versionDetailsDistribution: 'Distribution',
versionDetailsDirty: 'This package was built from a dirty source tree.',
versionDetailsDirtyUnknown: 'Built from a dirty source tree. The number of changes since the last release is unknown.',
connectionRetry: 'Check your connection and try again.',
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index 6bbc48ba4cb..050c604204b 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -1994,6 +1994,8 @@ export const ja = defineLocale({
versionDetailsBranch: 'ブランチ',
versionDetailsNoBranchInfo: 'ブランチ情報なし',
versionDetailsCommit: 'コミット',
+ versionDetailsSource: 'ソース',
+ versionDetailsDistribution: '配布形式',
versionDetailsDirty: 'このパッケージは変更のあるソースツリーからビルドされました。',
versionDetailsDirtyUnknown: '変更のあるソースツリーからビルドされました。最後のリリースからの変更数は不明です。',
connectionRetry: '接続を確認してもう一度試してください。',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 6d7211ec1eb..139081ad2b2 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -1773,6 +1773,8 @@ export interface Translations {
versionDetailsBranch: string
versionDetailsNoBranchInfo: string
versionDetailsCommit: string
+ versionDetailsSource: string
+ versionDetailsDistribution: string
versionDetailsDirty: string
versionDetailsDirtyUnknown: string
connectionRetry: string
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index 5b372a113c5..9dd70526801 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -1934,6 +1934,8 @@ export const zhHant = defineLocale({
versionDetailsBranch: '分支',
versionDetailsNoBranchInfo: '沒有分支資訊',
versionDetailsCommit: '提交',
+ versionDetailsSource: '來源',
+ versionDetailsDistribution: '發行方式',
versionDetailsDirty: '此套件從有未提交變更的來源樹建置。',
versionDetailsDirtyUnknown: '從有未提交變更的來源樹建置。自上次發布以來的變更數量未知。',
connectionRetry: '請檢查網路連線後重試。',
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index d8b05bfe5b5..d0a1a9e6c1f 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -2325,6 +2325,8 @@ export const zh: Translations = {
versionDetailsBranch: '分支',
versionDetailsNoBranchInfo: '无分支信息',
versionDetailsCommit: '提交',
+ versionDetailsSource: '来源',
+ versionDetailsDistribution: '发行方式',
versionDetailsDirty: '此软件包从有未提交更改的源代码树构建。',
versionDetailsDirtyUnknown: '从有未提交更改的源代码树构建。自上次发布以来的更改数量未知。',
connectionRetry: '请检查网络连接后重试。',
diff --git a/hermes_cli/main.py b/hermes_cli/main.py
index d315040f6b0..f34b5a9e580 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -4653,7 +4653,6 @@ def cmd_import(args):
def _print_version_info(*, check_updates: bool = True) -> None:
- from hermes_cli.config import detect_install_method
from hermes_cli.banner import format_banner_version_label
from hermes_cli.version_info import get_version_info
@@ -4665,8 +4664,10 @@ def _print_version_info(*, check_updates: bool = True) -> None:
print(f"Commit: {version_info.commit}")
if version_info.dirty is not None:
print(f"Working tree: {'dirty' if version_info.dirty else 'clean'}")
+ print(f"Source: {version_info.source}")
+ if version_info.distribution:
+ print(f"Distribution: {version_info.distribution}")
print(f"Install directory: {PROJECT_ROOT}")
- print(f"Install method: {detect_install_method(PROJECT_ROOT)}")
# Show Python version
print(f"Python: {sys.version.split()[0]}")
diff --git a/hermes_cli/version_info.py b/hermes_cli/version_info.py
index 32921d8c21e..f0a31100539 100644
--- a/hermes_cli/version_info.py
+++ b/hermes_cli/version_info.py
@@ -19,7 +19,7 @@ import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
-from typing import Literal
+from typing import Literal, cast
from hermes_cli import __release_date__, __version__
@@ -31,9 +31,10 @@ class VersionInfo:
distance: int | None
commit: str | None
branch: str | None
- source: Literal["git", "nix", "docker", "build", "unknown"]
+ source: Literal["build", "ci", "docker", "fallback", "git", "local", "nix", "unknown"]
dirty: bool = False
commit_date: int | None = None
+ distribution: Literal["docker", "nix"] | None = None
def format_display_version(info: VersionInfo | None = None) -> str:
@@ -125,18 +126,17 @@ def _stamp_version_info() -> VersionInfo | None:
if isinstance(distance, str):
distance = _parse_nonnegative(distance)
- # Normalize source labels — the stamp's "source" field describes the
- # build environment (ci/local/docker/nix/fallback), not the runtime
- # provenance path. Map known packaged sources to their runtime label.
+ # ``source`` describes build provenance, while ``distribution`` identifies
+ # the package form users installed. Keep both facts intact for support.
stamp_source = str(data.get("source") or "")
- if stamp_source in ("docker", "nix"):
- source: Literal["git", "nix", "docker", "build", "unknown"] = stamp_source
- elif stamp_source in ("ci", "local"):
- # CI/local stamps are still git-based provenance — the commit was
- # resolved from git at build time and baked into the stamp.
- source = "git"
- else:
- source = "build"
+ source = (
+ cast(Literal["build", "ci", "docker", "fallback", "git", "local", "nix", "unknown"], stamp_source)
+ if stamp_source in {"ci", "docker", "fallback", "local", "nix"}
+ else "build"
+ )
+ distribution = data.get("distribution")
+ if distribution not in {"docker", "nix"}:
+ distribution = None
commit_date = data.get("commitDate")
if not isinstance(commit_date, int):
@@ -151,6 +151,7 @@ def _stamp_version_info() -> VersionInfo | None:
source,
bool(data.get("dirty")),
commit_date,
+ distribution,
)
@@ -236,4 +237,6 @@ def format_version_details(info: VersionInfo | None = None) -> str:
if info.commit:
values.append(f"commit {info.commit}")
values.append(f"source {info.source}")
+ if info.distribution:
+ values.append(f"distribution {info.distribution}")
return " · ".join(values)
diff --git a/nix/desktop.nix b/nix/desktop.nix
index b71a7a5aab0..bca711ca250 100644
--- a/nix/desktop.nix
+++ b/nix/desktop.nix
@@ -164,7 +164,7 @@ let
cat > $out/install-stamp.json <<'EOF'
{"schemaVersion":2,"commit":${builtins.toJSON rev},"branch":${builtins.toJSON branch},"baseVersion":"${version}","displayVersion":"${displayVersion}","distance":${builtins.toJSON distance},"dirty":${
if dirty then "true" else "false"
- },"source":"nix","installMethod":"nix"}
+ },"source":"nix","distribution":"nix"}
EOF
cp -n apps/desktop/package.json $out/
diff --git a/scripts/write_install_stamp.py b/scripts/write_install_stamp.py
index d450d17cd6d..2cec1c94231 100644
--- a/scripts/write_install_stamp.py
+++ b/scripts/write_install_stamp.py
@@ -13,11 +13,11 @@ Usage::
# Override provenance for reproducible/packaged builds:
python scripts/write_install_stamp.py --output ... \\
--commit --branch --dirty \\
- --base-version 0.19.0 --distance 42 --source nix
+ --base-version 0.19.0 --distance 42 --source nix --distribution nix
# Docker (no .git, commit known from build arg):
python scripts/write_install_stamp.py --output /opt/hermes/.hermes_build_info.json \\
- --commit ${HERMES_GIT_SHA} --source docker
+ --commit ${HERMES_GIT_SHA} --source ci --distribution docker
"""
from __future__ import annotations
@@ -126,6 +126,7 @@ def build_stamp(
distance: int | None = None,
commit_date: int | None = None,
source: str = "local",
+ distribution: str | None = None,
) -> dict:
"""Build a stamp dict from explicit args, filling gaps from git/env.
@@ -181,6 +182,7 @@ def build_stamp(
"builtAt": datetime.now(timezone.utc).isoformat(),
"dirty": dirty,
"source": source,
+ "distribution": distribution,
"baseVersion": base_version,
"displayVersion": display_version,
"distance": distance,
@@ -206,6 +208,7 @@ def main() -> int:
parser.add_argument("--distance", type=int, default=None, help="Override commit distance")
parser.add_argument("--commit-date", type=int, default=None, help="Override commit timestamp (Unix epoch seconds)")
parser.add_argument("--source", default="local", help="Stamp source label")
+ parser.add_argument("--distribution", choices=("docker", "nix"), help="Package distribution")
args = parser.parse_args()
stamp = write_stamp(
@@ -217,6 +220,7 @@ def main() -> int:
distance=args.distance,
commit_date=args.commit_date,
source=args.source,
+ distribution=args.distribution,
)
commit_short = stamp["commit"][:12]
diff --git a/tests/hermes_cli/test_version_info.py b/tests/hermes_cli/test_version_info.py
index 4a23f771a05..845a0ba9368 100644
--- a/tests/hermes_cli/test_version_info.py
+++ b/tests/hermes_cli/test_version_info.py
@@ -37,6 +37,7 @@ def test_stamp_version_info_reads_nix_stamp(tmp_path, monkeypatch):
"distance": 3,
"dirty": False,
"source": "nix",
+ "distribution": "nix",
}
stamp_file = tmp_path / ".hermes_build_info.json"
stamp_file.write_text(json.dumps(stamp))
@@ -44,7 +45,33 @@ def test_stamp_version_info_reads_nix_stamp(tmp_path, monkeypatch):
info = get_version_info()
- assert info == VersionInfo("0.19.0", "0.19.0+3", 3, "a" * 40, "feature/version", "nix")
+ assert info == VersionInfo("0.19.0", "0.19.0+3", 3, "a" * 40, "feature/version", "nix", distribution="nix")
+
+
+def test_stamp_version_info_preserves_ci_provenance_and_docker_distribution(tmp_path, monkeypatch):
+ stamp = {"commit": "d" * 40, "source": "ci", "distribution": "docker"}
+ stamp_file = tmp_path / ".hermes_build_info.json"
+ stamp_file.write_text(json.dumps(stamp))
+ monkeypatch.setattr("hermes_cli.version_info._resolve_stamp_file", lambda: stamp_file)
+
+ info = get_version_info()
+
+ assert info.source == "ci"
+ assert info.distribution == "docker"
+
+
+def test_version_command_shows_provenance_and_distribution(monkeypatch, capsys):
+ import hermes_cli.main as main
+
+ info = VersionInfo("0.19.0", "0.19.0", None, "a" * 40, "main", "ci", distribution="docker")
+ monkeypatch.setattr("hermes_cli.banner.format_banner_version_label", lambda: "Hermes Agent v0.19.0")
+ monkeypatch.setattr("hermes_cli.version_info.get_version_info", lambda: info)
+
+ main._print_version_info(check_updates=False)
+
+ output = capsys.readouterr().out
+ assert "Source: ci" in output
+ assert "Distribution: docker" in output
def test_stamp_version_info_preserves_missing_branch(tmp_path, monkeypatch):
diff --git a/tests/scripts/test_write_install_stamp.py b/tests/scripts/test_write_install_stamp.py
new file mode 100644
index 00000000000..0d7e02c3001
--- /dev/null
+++ b/tests/scripts/test_write_install_stamp.py
@@ -0,0 +1,8 @@
+from scripts.write_install_stamp import build_stamp
+
+
+def test_build_stamp_keeps_provenance_separate_from_distribution():
+ stamp = build_stamp(commit="a" * 40, source="ci", distribution="docker")
+
+ assert stamp["source"] == "ci"
+ assert stamp["distribution"] == "docker"
\ No newline at end of file
diff --git a/tui_gateway/server.py b/tui_gateway/server.py
index 36c28956db8..0b82c1d8783 100644
--- a/tui_gateway/server.py
+++ b/tui_gateway/server.py
@@ -4486,6 +4486,7 @@ def _session_info(agent, session: dict | None = None) -> dict:
info["version_commit"] = version_info.commit or ""
info["version_branch"] = version_info.branch or ""
info["version_source"] = version_info.source
+ info["version_distribution"] = version_info.distribution or ""
info["version_dirty"] = version_info.dirty
except Exception:
pass