diff --git a/apps/desktop/scripts/before-build.mjs b/apps/desktop/scripts/before-build.mjs
index acb4d5b14c..9dd0ba3856 100644
--- a/apps/desktop/scripts/before-build.mjs
+++ b/apps/desktop/scripts/before-build.mjs
@@ -102,12 +102,53 @@ function writeMsixExtensions() {
-${aliases}`
+${aliases}${serviceExtensions()}`
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, copilot)
}
+/**
+ * The desktop6:Service fragment registering HermesGateway as a Windows
+ * Service (plan: gateway-msix-windows-service; Task 2).
+ *
+ * The service Executable is the payload's own launcher (hermes.exe —
+ * the distlib-minted PE that already serves the AppExecutionAliases):
+ * its `gateway run --service` mode is the SCM frontend
+ * (gateway/windows_service.py). NO separate shim binary.
+ *
+ * Settled decisions baked here (2026-09-03, ethie): user-context
+ * service (StartAccount omitted — desktop6 defaults to the installing
+ * user's context; localSystem explicitly rejected), demand StartupType
+ * (config-only posture: the service arrives STOPPED; `hermes gateway
+ * service on` flips it to automatic-at-logon).
+ *
+ * Content rules (the 0x80080204 playbook): xmlns:desktop6 rides the
+ * fragment root; ONE extension block; Executable must name a real in-
+ * package exe. Requires Win10 1903+ — the minVersion bump in
+ * gen-msix-manifest.mjs matches. Exported pure for tests.
+ */
+export function serviceExtensions({
+ name = 'HermesGateway',
+ executable = ['app', 'resources', 'agent-payload', 'bin', 'hermes.exe'].join(String.fromCharCode(92)),
+ variant = 'bundled',
+} = {}) {
+ // light variant ships no payload → no service; the store variant is
+ // decided by Store policy separately (plan risk item) — both render
+ // service-less for now.
+ if (variant === 'light' || variant === 'store') return ''
+ return `
+
+
+`
+}
+
/**
* One uap5:Extension block per payload CLI launcher, each naming its own
* Executable (the distlib-minted launcher exes under bin/) and the alias
diff --git a/apps/desktop/scripts/cli-launchers.test.mjs b/apps/desktop/scripts/cli-launchers.test.mjs
index d569cfeda4..446c5344a8 100644
--- a/apps/desktop/scripts/cli-launchers.test.mjs
+++ b/apps/desktop/scripts/cli-launchers.test.mjs
@@ -115,3 +115,46 @@ test('light variant emits no alias fragments', () => {
assert.ok(appExecutionAliasExtensions(['hermes']).includes('windows.appExecutionAlias'))
assert.ok(scriptDir.length > 0)
})
+
+// ── HermesGateway Windows Service fragment (Task 2, plan:
+// gateway-msix-windows-service) ────────────────────────────────────────
+import { serviceExtensions } from './before-build.mjs'
+
+test('bundled variant registers HermesGateway, demand-start, launcher exe', () => {
+ const frag = serviceExtensions()
+ assert.ok(frag.includes('Category="windows.service"'), 'service category')
+ assert.ok(frag.includes('Name="HermesGateway"'), 'the service name the CLI verbs key off')
+ assert.ok(frag.includes('StartupType="demand"'), 'config-only posture: arrives stopped')
+ // Compose the expected path the same way the source does — no escaping
+ // ambiguity (the appExecutionAliasExtensions precedent).
+ const bs = String.fromCharCode(92)
+ const launcherPath = ['app', 'resources', 'agent-payload', 'bin', 'hermes.exe'].join(bs)
+ assert.ok(
+ frag.includes(`Executable="${launcherPath}"`),
+ 'the service Executable is the payload launcher (no shim binary)'
+ )
+ assert.ok(!frag.includes('StartAccount'), 'user-context by default — localSystem rejected')
+})
+
+test('light and store variants render service-less', () => {
+ assert.equal(serviceExtensions({ variant: 'light' }), '')
+ assert.equal(serviceExtensions({ variant: 'store' }), '')
+})
+
+test('one desktop6 extension block with xmlns on the fragment root', () => {
+ const frag = serviceExtensions()
+ assert.equal(
+ (frag.match(/ {
+ const frag = serviceExtensions({ name: 'HermesGateway_Tag1' })
+ assert.ok(frag.includes('Name="HermesGateway_Tag1"'))
+})
diff --git a/apps/desktop/scripts/gen-msix-manifest.mjs b/apps/desktop/scripts/gen-msix-manifest.mjs
index da1a5972ce..798dc36d22 100644
--- a/apps/desktop/scripts/gen-msix-manifest.mjs
+++ b/apps/desktop/scripts/gen-msix-manifest.mjs
@@ -105,8 +105,11 @@ const manifest = substituteManifestMacros(template, (m) => {
case "resourceLanguages": return resourceLanguageTag(options.languages)
case "capabilities": return `\n${buildCapabilitiesXml(options.capabilities)}\n`
case "extensions": return extensions
- case "minVersion": return options.minVersion || "10.0.17763.0"
- case "maxVersionTested": return options.maxVersionTested || options.minVersion || "10.0.17763.0"
+ // Win10 1903 (build 18362) floor: desktop6:Service — the MSIX-shipped
+ // HermesGateway Windows Service — requires 1903+; settled 2026-09-03.
+ // 1809 is a 2018 OS; the bump rides the release notes.
+ case "minVersion": return options.minVersion || "10.0.18362.0"
+ case "maxVersionTested": return options.maxVersionTested || options.minVersion || "10.0.18362.0"
case "packageIntegrity": return ""
default: throw new Error(`Macro ${m} is not defined`)
}